api

package
v0.0.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 42 Imported by: 0

Documentation

Index

Constants

View Source
const (
	AttachmentMaxFileBytes  int64 = 10 * 1024 * 1024 // 10 MB per file
	AttachmentMaxTotalBytes int64 = 30 * 1024 * 1024 // 30 MB per task dir
	AttachmentMaxNameBytes        = 255
)

Attachment-related constants. Values are also enforced client-side in web/static/boid-paste-attach.js; keep the two in sync.

Variables

View Source
var AttachmentAllowedExts = map[string]bool{
	".png":  true,
	".jpg":  true,
	".jpeg": true,
	".webp": true,
	".txt":  true,
	".md":   true,
	".json": true,
	".log":  true,
}

AttachmentAllowedExts is the file-extension allowlist for uploads. Both images (for screenshot paste) and a few text formats (for logs / json snippets) are accepted.

View Source
var ErrAskPending = errors.New("task_ask: another question is pending")

ErrAskPending is returned by BlockingAskRegistry.Register when the task already has an outstanding blocking ask. It implements decision B1: a second concurrent `boid task ask` for the same task fails immediately rather than queueing behind the first.

Functions

func AttachmentsRootForTask added in v0.0.7

func AttachmentsRootForTask(dataHome, taskID string) string

AttachmentsRootForTask resolves the on-disk attachments directory for the given task. dataHome is the data root (e.g. filepath.Dir(cfg.DBPath)), the same convention as runtimesDirFor.

taskID must be a single canonical path component (codex review on PR #798, Phase 5b PR2 attachments RPCs): CreateTaskRequest.ID is caller-supplied and saved as the literal DB primary key without validation (internal/api/task_create.go), and the broker (internal/sandbox/broker.go) authorizes attachments ops by comparing that *raw* TaskID string against the token's own context — never a resolved filesystem path. Without this guard, a task literally IDed "alias/../<victim-id>" would pass the broker's string-equality check trivially (both sides carry the identical literal alias) while filepath.Join here silently collapsed it down to the *victim's* real attachments directory — a cross-task leak. Rejecting a non-canonical taskID here (returning "", the same fail-closed sentinel already used for an empty dataHome/taskID) protects every caller uniformly: the write path (EnsureAttachmentsDir, SaveMultipartAttachments) and the read path (ListAttachments, ReadAttachment) all resolve through this one function.

func BuildFlatItems

func BuildFlatItems(tasks []*orchestrator.Task, projectNames map[string]string) []components.TreeItem

BuildFlatItems returns tasks as a flat list (Depth=0, HasChildren=false, ParentID=""). Used for the "closed" status view where tree structure is irrelevant.

func BuildTreeItems

func BuildTreeItems(tasks []*orchestrator.Task, projectNames map[string]string) []components.TreeItem

BuildTreeItems takes a flat task list and returns items in DFS tree order with depth and visual-parent info. Siblings appear in input order. Tasks whose ParentID is absent from the list are treated as roots. Cycles in ParentID references are detected via a visited set and skipped.

projectNames maps project ID to display name; pass nil to skip name resolution.

func EnsureAttachmentsDir added in v0.0.7

func EnsureAttachmentsDir(dataHome, taskID string) (string, error)

EnsureAttachmentsDir creates the per-task attachments directory if it does not already exist. Used by handlers right after task creation / before accepting answer attachments so the bind mount has a non-empty source.

func ListAttachments added in v0.0.13

func ListAttachments(dataHome, taskID string) ([]string, error)

ListAttachments returns the basenames of the regular files directly under the task's attachments directory, sorted. Subdirectories are never recursed into — attachments are a flat namespace. A task that has never received an attachment (missing directory) returns an empty, non-nil slice rather than an error, matching the RPC's "no data" convention (JSON `[]`, not `null`).

Every symlink is excluded outright, regardless of where it points — codex review on PR #798 (Nit) flagged that the original version only filtered *escaping* symlinks, which could advertise a name ReadAttachment (which now rejects every symlink categorically, via openat2 RESOLVE_NO_SYMLINKS on Linux — see the Major/TOCTOU fix) would then always 404 on. Non-regular entries (FIFOs, sockets, devices — never created by SaveMultipartAttachments, but not something a shared filesystem rules out) are excluded for the same reason: list's admission criteria must exactly match what get can actually serve.

func NormalizePublicURL added in v0.0.13

func NormalizePublicURL(raw string) (string, error)

NormalizePublicURL parses raw as an HTTPS origin, strips path/query/fragment, lowercases and IPv6-brackets the host, and returns the canonical `https://host[:port]` form. It exists so wire.go can validate cfg.Web.PublicURL once at daemon startup and hand the canonical form to DeviceAuthHandler.PublicURL — the value that ends up in the POST /api/auth/device response, which the CLI (PR2) will byte-compare against its saved profile URL on every subsequent request. An un-normalized value (path, trailing slash, `HOST.EXAMPLE.COM`, plain http, empty host like `https://:443`) would either fail the CLI's origin-bind check later or, worse, get accepted and silently misroute future requests.

This is also the exact same normalization applied to the request Host header fallback inside DeviceAuthHandler.canonicalURL — see the code path there for the "fallback must not bypass this validator" rule.

Empty raw is not an error (PublicURL is optional at boot — the handler falls back to the request's Host header). A non-empty raw that fails validation is returned as an error so the operator sees the misconfig at startup instead of at the first pair attempt.

func ReadAttachment added in v0.0.13

func ReadAttachment(dataHome, taskID, name string) ([]byte, error)

ReadAttachment returns the bytes of one attachment, addressed by its exact basename. name must be a single canonical path component: a traversal or absolute-path attempt ("../../etc/passwd", "/etc/passwd") is rejected by validateAttachmentLookupName before any path is constructed, rather than being silently coerced into some *other* file the way SanitizeAttachmentName's upload-time "pick a safe name to store under" contract does. The actual open+read (readAttachmentBytes, platform- specific — see attachment_read_linux.go) opens the file exactly once and reuses that descriptor for both the containment/type check and the capped read, closing the TOCTOU window a separate check-then-reopen sequence would leave (codex review on PR #798).

func ResolveJSONField added in v0.0.13

func ResolveJSONField(raw json.RawMessage, path string) (string, error)

ResolveJSONField resolves a dotted path against arbitrary already-marshaled JSON data, using the same segment-traversal / value-formatting rules as ResolveTaskField (missing path → "", scalar → unquoted/stringified, object/array → compact JSON, traversing into a scalar → error). Unlike ResolveTaskField it does no task-specific implicit `payload.` prefixing or `lifecycle` injection — every other Phase 5b PR1 task-context RPC (`boid task current` / `instructions` / `env` / `payload`, docs/plans/phase5-shim-and-task-context.md) shares this generic core so `--field` behaves identically everywhere task_get already established the contract.

func ResolveTaskField

func ResolveTaskField(task *orchestrator.Task, actions orchestrator.LifecycleStore, path string) (string, error)

ResolveTaskField returns the value at the given dotted path against the task.

Path resolution:

  • Each dot-separated segment is a JSON key.
  • The first segment is matched against top-level Task fields (via JSON tags). When it doesn't match, the path is implicitly resolved inside `payload`, so traits like `awaiting.question`, `artifact.report`, and `lifecycle.abort.message` work without an explicit `payload.` prefix.
  • `lifecycle` is a computed trait derived from action history. When lifecycle is referenced, ResolveTaskField calls DeriveLifecycle (when actions is non-nil) and injects the result under `payload.lifecycle` before traversal.

Output format:

  • Strings: returned unquoted.
  • Numbers and booleans: stringified.
  • Objects and arrays: compact JSON.
  • Missing path: returns "" with no error.
  • Traversing into a scalar with remaining path segments: error.

func SanitizeAttachmentName added in v0.0.7

func SanitizeAttachmentName(raw string) (string, error)

SanitizeAttachmentName validates a user-supplied filename and returns the cleaned basename. It rejects anything that doesn't match attachmentNameRe, exceeds the byte cap, has a disallowed extension, or contains only a dot prefix (".env" etc., which the regex would otherwise allow).

func SaveMultipartAttachments added in v0.0.7

func SaveMultipartAttachments(dataHome, taskID string, files []*multipart.FileHeader) ([]string, error)

SaveMultipartAttachments persists every file under the multipart form field "attachments" into the task-scoped attachments directory. It enforces:

  • per-file size cap (AttachmentMaxFileBytes)
  • aggregate task-dir size cap (AttachmentMaxTotalBytes)
  • filename sanitization (SanitizeAttachmentName)

On success it returns the list of saved basenames. On any error it removes the files it had partially written during this call so the caller can surface the error without leaving orphans behind.

func ValidateAttachmentHeaders added in v0.0.7

func ValidateAttachmentHeaders(files []*multipart.FileHeader) error

ValidateAttachmentHeaders runs the size + name checks that SaveMultipartAttachments would apply, without touching the filesystem. Web handlers call this *before* creating the task so a bad upload (oversized, bad extension, disallowed name) doesn't leave a half-created task behind.

Types

type ActionApplication

type ActionApplication struct {
	Task         *orchestrator.Task   `json:"task"`
	Action       *orchestrator.Action `json:"action"`
	MatchedHooks []string             `json:"matched_hooks,omitempty"`
}

type ActionHandler

type ActionHandler struct {
	Service WorkflowService
}

func (*ActionHandler) Apply

func (h *ActionHandler) Apply(w http.ResponseWriter, r *http.Request)

func (*ActionHandler) Routes

func (h *ActionHandler) Routes() chi.Router

type ActionStore

type ActionStore interface {
	CreateAction(action *orchestrator.Action) error
	ListActionsByTask(taskID string) ([]*orchestrator.Action, error)
}

type AnswerTaskRequest

type AnswerTaskRequest struct {
	QuestionID string `json:"question_id"`
	Answer     string `json:"answer"`
}

type ApplyActionRequest

type ApplyActionRequest struct {
	Type    string          `json:"type"`
	Payload json.RawMessage `json:"payload,omitempty"`
}

type BlockingAskRegistry added in v0.0.6

type BlockingAskRegistry struct {
	// contains filtered or unexported fields
}

BlockingAskRegistry coordinates harness-independent blocking Q&A. When an agent calls `boid task ask`, the broker holds the RPC connection open and the server-side handler blocks in Wait until the user/supervisor answers (via TaskHandler.Answer → AnswerTask → Notify) or the context is cancelled (daemon shutdown / agent disconnect).

The registry is purely in-memory: a daemon restart drops every pending ask. That is consistent — the sandbox process backing the blocked RPC dies with the daemon too, so there is nothing to resume.

func NewBlockingAskRegistry added in v0.0.6

func NewBlockingAskRegistry() *BlockingAskRegistry

NewBlockingAskRegistry returns an initialised registry.

func (*BlockingAskRegistry) Cancel added in v0.0.6

func (r *BlockingAskRegistry) Cancel(qid string)

Cancel removes the registration for qid (and the task it belongs to). It does not unblock Wait by itself — a blocked Wait observes ctx cancellation — but it frees the B1 slot so the task can ask again. Idempotent and safe to defer.

func (*BlockingAskRegistry) Has added in v0.0.6

func (r *BlockingAskRegistry) Has(qid string) bool

Has reports whether qid currently has a registration. Intended for tests and diagnostics.

func (*BlockingAskRegistry) Notify added in v0.0.6

func (r *BlockingAskRegistry) Notify(qid, answer string) bool

Notify delivers answer to the waiter registered for qid. It returns true when a registration exists and the answer was accepted, false when no waiter is registered (e.g. the agent already disconnected and Cancel ran) or an answer was already delivered. The send is non-blocking thanks to the buffered channel, so Notify never blocks even if Wait has not yet been scheduled.

func (*BlockingAskRegistry) Register added in v0.0.6

func (r *BlockingAskRegistry) Register(taskID, qid string) error

Register reserves the answer channel for (taskID, qid). It MUST be called before the task transitions to awaiting so an answer that arrives immediately afterwards is never dropped. The check-and-insert is atomic under the registry lock, so two concurrent asks for the same task can never both succeed.

B1 (relaxed): a second pending ask for the same task fails with ErrAskPending ONLY when it carries a DIFFERENT question id. Re-registering the SAME qid is a re-attach — an agent whose `boid task ask` was killed by a harness command-timeout retrying the identical question — and is allowed. The re-attach installs a fresh channel; any prior waiter is already tearing down on its cancelled context. (Even if its deferred Cancel later clobbers this channel, the durable PendingAnswer path backstops delivery, so at worst the fast path degrades to one extra ask round-trip.)

func (*BlockingAskRegistry) Wait added in v0.0.6

func (r *BlockingAskRegistry) Wait(ctx context.Context, qid string) (string, error)

Wait blocks until an answer is delivered for qid via Notify or ctx is cancelled. qid must have been registered; an unknown qid is a programming error and returns immediately. The caller is responsible for Cancel(qid) cleanup (typically via defer) on every exit path.

type BrokerHandler

type BrokerHandler struct {
	Registry BrokerRegistry
}

func (*BrokerHandler) Register

func (h *BrokerHandler) Register(w http.ResponseWriter, r *http.Request)

func (*BrokerHandler) Routes

func (h *BrokerHandler) Routes() chi.Router

type BrokerRegisterRequest

type BrokerRegisterRequest struct {
	Commands        map[string]orchestrator.HostCommandSpec `json:"commands"`
	BuiltinPolicies map[string]sandbox.BuiltinPolicy        `json:"builtin_policies,omitempty"`
	ProjectID       string                                  `json:"project_id,omitempty"`
}

type BrokerRegisterResponse

type BrokerRegisterResponse struct {
	Token  string `json:"token"`
	Socket string `json:"socket"`
	// ResolvedHostCommands used to echo back
	// dispatcher.ResolveHostCommands' absolute-path-keyed map so `boid exec`
	// could feed it into SandboxRuntimeInfo (matching shim bind-mount
	// targets to broker policy keys without re-resolving on the client
	// side). That map has been dead weight since the Phase 5 5a-3 cutover
	// retired the shim's absolute-host-path bind mount scheme
	// (SandboxRuntimeInfo.ResolvedHostCommands was deleted, hostCommandMounts
	// and buildHostCommandNamesEnv with it — see
	// docs/plans/phase5-shim-and-task-context.md 5a PR3). No client reads
	// this field any more; it is retained as omitempty for one release so
	// any stale caller decoding the response doesn't blow up on a missing
	// key, and will be removed outright once no versions of the CLI still
	// speak the older shape.
	ResolvedHostCommands map[string]orchestrator.CommandDef `json:"resolved_host_commands,omitempty"`
}

type BrokerRegistry

type BrokerRegistry interface {
	RegisterBrokerCommands(commands map[string]orchestrator.HostCommandSpec, builtinPolicies map[string]sandbox.BuiltinPolicy, projectID string) (*BrokerRegisterResponse, error)
}

type ConfigHandler added in v0.0.13

type ConfigHandler struct {
	Service ConfigService
}

func (*ConfigHandler) KitsDir added in v0.0.13

func (h *ConfigHandler) KitsDir(w http.ResponseWriter, r *http.Request)

KitsDir handles GET /api/config/kits-dir: the daemon's effective KitsDir, so a CLI client-side helper (cmd/workspace.go's ensureWorkspaceExistsForAssign, MAJOR 1) can resolve a legacy workspace.yaml's `kits:` references against the *running daemon's* kits directory instead of independently re-deriving a default that silently disagrees with it whenever the daemon was started with a custom --kits-dir.

func (*ConfigHandler) Routes added in v0.0.13

func (h *ConfigHandler) Routes() chi.Router

type ConfigService added in v0.0.13

type ConfigService interface {
	// KitsDir returns the daemon's effective base directory for installed
	// kits (server.Config.KitsDir, resolved once at startup — see
	// cmd/start.go's defaultKitsDir()/--kits-dir flag).
	KitsDir() string
}

ConfigService is the daemon-side dependency behind GET /api/config/kits-dir (MAJOR 1, codex review round 1, docs/plans/workspace-db-consolidation.md Phase 2.5 PR7): a small read-only surface exposing daemon startup config that only the running daemon actually knows, since it may have been overridden at `boid start` time (--kits-dir) and never reaches whatever default a CLI client-side helper would otherwise (re-)derive on its own. Implemented directly by *server.Server (its KitsDir() method already matches this shape) — no adapter type is needed, mirroring HostCommandsService's own convention in this package.

type CreateProjectRequest

type CreateProjectRequest struct {
	WorkDir string `json:"work_dir"`
}

type CreateTaskRequest

type CreateTaskRequest struct {
	ID           string                     `json:"id,omitempty"`
	ProjectID    string                     `json:"project_id"`
	Title        string                     `json:"title"`
	Description  string                     `json:"description,omitempty"`
	Behavior     string                     `json:"behavior,omitempty"`
	BehaviorSpec *orchestrator.BehaviorSpec `json:"behavior_spec,omitempty"`
	RemoteID     string                     `json:"remote_id,omitempty"`
	Payload      json.RawMessage            `json:"payload,omitempty"`
	Instructions json.RawMessage            `json:"instructions,omitempty"`
	AutoStart    bool                       `json:"auto_start,omitempty"`
	Traits       []string                   `json:"traits,omitempty"`
	Ref          string                     `json:"ref,omitempty"`
	ParentID     string                     `json:"parent_id,omitempty"`
	Readonly     *bool                      `json:"readonly,omitempty"`
}

type DeviceAuthHandler added in v0.0.13

type DeviceAuthHandler struct {
	Pairing   loginPairing
	Store     deviceAuthStore
	Limiter   loginRateLimiter
	Registry  *auth.ConnectionRegistry
	PublicURL string
}

DeviceAuthHandler serves the Bearer device-auth endpoints used by remote CLI clients (docs/plans/cli-remote-connection.md Phase 3 PR0):

  • POST /api/auth/device — public, rate-limited. Redeems a pairing code (the same one `boid web pair` issues for the cookie flow) for a long-lived Bearer device token.
  • DELETE /api/auth/devices/{id} — Bearer-authenticated, self-revoke only.

It is deliberately a separate surface from WebManagementHandler (mounted at /api/web/*, UNIX-socket-only, trusted local-admin surface with no ownership check: /api/web/pair, /api/web/devices) — this handler is reachable over TCP by a caller who starts out holding nothing but a valid pairing code, so PostDevice carries its own rate limiting and DeleteDevice enforces "only revoke yourself".

func (*DeviceAuthHandler) DeleteDevice added in v0.0.13

func (h *DeviceAuthHandler) DeleteDevice(w http.ResponseWriter, r *http.Request)

DeleteDevice revokes the caller's own device only, and only when the caller authenticated via Bearer token. The {id} path param must match the Bearer-authenticated device ID the auth middleware placed in the request context (auth.DeviceIDFromContext + auth.AuthMethodFromContext).

The auth-method check exists because a session-cookie caller has other, richer paths for managing their own devices (the WebManagementHandler /api/web/devices UNIX-socket surface used by `boid web revoke`) — this endpoint is explicitly the *Bearer* self-revoke path used by `boid logout`, and mixing the two auth models on the same route would muddle the responsibility split.

This is intentionally stricter than WebManagementHandler.DeleteDevice (UNIX-socket-only local admin, no ownership check) — this endpoint is reachable by any Bearer-holding remote caller, so it must not let one device revoke another's token.

func (*DeviceAuthHandler) PostDevice added in v0.0.13

func (h *DeviceAuthHandler) PostDevice(w http.ResponseWriter, r *http.Request)

PostDevice redeems a one-time pairing code (auth.PairingManager — the same manager instance the cookie-based /login and /auth flows share) for a long-lived Bearer device token. Unlike those flows, no session cookie is ever set here: the raw token is returned exactly once, in the response body, and only its SHA-256 hash (auth.HashToken) is ever persisted (Store.InsertDeviceToken).

func (*DeviceAuthHandler) Routes added in v0.0.13

func (h *DeviceAuthHandler) Routes() chi.Router

type DeviceGCStore

type DeviceGCStore interface {
	DeleteRevokedDevices(ctx context.Context, dryRun bool) (int64, error)
}

type DispatchCoordinator

type DispatchCoordinator interface {
	DispatchAndAdvance(ctx context.Context, task *orchestrator.Task, meta *orchestrator.ProjectMeta, sm *orchestrator.StateMachine) (*orchestrator.DispatchResult, error)
	ReplayHook(ctx context.Context, task *orchestrator.Task, meta *orchestrator.ProjectMeta, sm *orchestrator.StateMachine, hookID string) (*orchestrator.ReplayResult, error)
}

type DuplicateTaskRequest

type DuplicateTaskRequest struct {
	AutoStart bool `json:"auto_start"`
}

type ExecDispatcher added in v0.0.10

type ExecDispatcher interface {
	StartExec(ctx context.Context, req StartExecRequest) (*StartExecResult, error)
}

ExecDispatcher launches a JobKindExec job (arbitrary argv, shell harness, no HarnessAdapter agent) through Runner.Dispatch() and returns the runtime job id. Implemented by internal/server's sessionDispatcherAdapter.

type GCAppService

type GCAppService struct {
	Store       GCStore
	DeviceStore DeviceGCStore // optional; deletes revoked devices on GC
}

func (*GCAppService) GC

func (s *GCAppService) GC(olderThan time.Duration, dryRun bool) (*orchestrator.GCResult, error)

GC implements orchestrator.GCStore so GCAppService can be passed to GCLoop.

func (*GCAppService) Run

func (s *GCAppService) Run(olderThan time.Duration, dryRun bool) (*orchestrator.GCResult, error)

type GCHandler

type GCHandler struct {
	Service GCService
	// RuntimesDir, when non-empty, is server/wire.go's runtimesDirFor(cfg) —
	// used to list workspace home directories and their sizes in the
	// response (docs/plans/home-workspace-volume.md Phase 4 PR5:
	// "サイズ可視化のみで開始、自動 prune なし"). Left empty, the response
	// omits workspace_homes entirely — no size listing, and (unchanged from
	// pre-PR5) no home directory is ever deleted by GC either way.
	RuntimesDir string
	// Workspaces, when set, is consulted to flag orphaned home directories
	// (a homes/<slug> directory with no corresponding workspace row) in the
	// workspace_homes listing. Optional: a nil Workspaces just means every
	// entry reports orphan=true (ListWorkspaceHomeSizes's degrade-gracefully
	// path — see its doc comment).
	Workspaces WorkspaceSlugLister
}

func (*GCHandler) Routes

func (h *GCHandler) Routes() chi.Router

func (*GCHandler) Run

func (h *GCHandler) Run(w http.ResponseWriter, r *http.Request)

type GCService

type GCService interface {
	Run(olderThan time.Duration, dryRun bool) (*orchestrator.GCResult, error)
}

type GCStore

type GCStore interface {
	GC(olderThan time.Duration, dryRun bool) (*orchestrator.GCResult, error)
}

type GlobalJobStore

type GlobalJobStore interface {
	ListJobsWithContext(filter JobListFilter) ([]JobWithContext, error)
}

GlobalJobStore supports cross-task job listing with context (task title, project name).

type HookService

type HookService interface {
	ReplayHook(ctx context.Context, taskID string, req ReplayHookRequest) (*ReplayHookResult, error)
	ListHooksForStatus(taskID, status string) ([]orchestrator.Hook, error)
}

HookService provides hook replay and hook listing operations.

type HostCommandsHandler added in v0.0.13

type HostCommandsHandler struct {
	Service HostCommandsService
}

func (*HostCommandsHandler) List added in v0.0.13

List handles GET /api/host_commands: the sorted list of host_commands names known to the daemon (docs/plans/workspace-db-consolidation.md plan doc contract: "参照名一覧を返す契約"), useful for Web UI / CLI (`boid host-commands list`) validation of a workspace's host_commands references. MINOR 1 (codex review): this used to return the full aggregated definition map (path/env/policy per name) instead of just the name list the plan doc specifies — a caller validating references has no business seeing (or needing to parse) the internal command policy.

func (*HostCommandsHandler) Reload added in v0.0.13

Reload handles POST /api/host_commands/reload: re-read the aggregated config from disk after a hand edit (the plan doc's documented way to add/adjust a host_command without a kit).

func (*HostCommandsHandler) Routes added in v0.0.13

func (h *HostCommandsHandler) Routes() chi.Router

type HostCommandsProvider added in v0.0.13

type HostCommandsProvider interface {
	HostCommands() map[string]orchestrator.HostCommandSpec
}

HostCommandsProvider exposes the daemon's live aggregated host_commands snapshot (name -> spec) for reference-name validation on workspace create/update (docs/plans/workspace-db-consolidation.md MAJOR 2, codex review: an unresolvable meta.HostCommands reference must be rejected with 400 at write time rather than silently persisted and later warned-about + skipped at dispatch). Implemented by *server.Server, which already exposes this exact method for HostCommandsService above.

type HostCommandsService added in v0.0.13

type HostCommandsService interface {
	// HostCommands returns a snapshot of the daemon's aggregated
	// host_commands config (raw/unexpanded — see
	// orchestrator.ExpandHostCommandsForDispatch's doc comment for why).
	HostCommands() map[string]orchestrator.HostCommandSpec
	// ReloadHostCommands re-reads ~/.config/boid/host_commands.yaml from
	// disk and swaps it in, live, without a daemon restart.
	ReloadHostCommands() error
}

HostCommandsService is the daemon-side dependency behind GET /api/host_commands and POST /api/host_commands/reload (docs/plans/workspace-db-consolidation.md PR4 Step G). Implemented directly by *server.Server (its HostCommands()/ReloadHostCommands() methods already match this shape) — no adapter type is needed, unlike some of the other *Handler.Service fields in this package.

type ImportError

type ImportError struct {
	Line     int    `json:"line"`
	RemoteID string `json:"remote_id"`
	Error    string `json:"error"`
}

type ImportResult

type ImportResult struct {
	Created int           `json:"created"`
	Skipped int           `json:"skipped"`
	Errors  []ImportError `json:"errors"`
}

type Job

type Job struct {
	ID                    string     `json:"id"`
	TaskID                string     `json:"task_id"`
	ProjectID             string     `json:"project_id"`
	HandlerID             string     `json:"handler_id"`
	DisplayName           string     `json:"display_name,omitempty"`
	Role                  string     `json:"role"`
	RuntimeID             string     `json:"runtime_id,omitempty"`
	WorkspacePath         string     `json:"workspace_path,omitempty"`
	Interactive           bool       `json:"interactive"`
	TTY                   bool       `json:"tty"`
	Status                JobStatus  `json:"status"`
	ExitCode              int        `json:"exit_code,omitempty"`
	Output                string     `json:"output,omitempty"`
	ExecutionState        string     `json:"execution_state,omitempty"`
	CreatedAt             time.Time  `json:"created_at"`
	UpdatedAt             time.Time  `json:"updated_at"`
	TranscriptSize        int64      `json:"transcript_size,omitempty"`
	TranscriptMtime       *time.Time `json:"transcript_mtime,omitempty"`
	TranscriptIdleSeconds int64      `json:"transcript_idle_seconds,omitempty"`
}

type JobCompletion

type JobCompletion struct {
	Output   string
	ExitCode int
}

type JobDoneRequest

type JobDoneRequest struct {
	ExitCode int    `json:"exit_code"`
	Output   string `json:"output,omitempty"`
}

type JobHandler

type JobHandler struct {
	Jobs       JobStore
	Global     GlobalJobStore // optional: enables cross-task listing when task_id is absent
	Service    WorkflowService
	LogReader  JobLogReader // optional: enables static GET /{id}/log
	SSEHandler http.Handler // optional: enables SSE streaming for GET /{id}/log?follow=true
}

func (*JobHandler) AgentStop

func (h *JobHandler) AgentStop(w http.ResponseWriter, r *http.Request)

AgentStop asks the daemon to deliver SIGUSR1 to the runtime pgrp. claude.Adapter.Run() catches the signal and forwards SIGTERM to the claude child while the surrounding runner-inner-child keeps running and posts `boid job done --output-file payload_patch.json` through the broker — that callback remains the canonical CompleteJob caller, preserving the agent's session id without racing against UnregisterJob. See WorkflowService.StopAgent for the lifecycle rationale.

func (*JobHandler) Done

func (h *JobHandler) Done(w http.ResponseWriter, r *http.Request)

func (*JobHandler) Get

func (h *JobHandler) Get(w http.ResponseWriter, r *http.Request)

func (*JobHandler) List

func (h *JobHandler) List(w http.ResponseWriter, r *http.Request)

func (*JobHandler) Log

func (h *JobHandler) Log(w http.ResponseWriter, r *http.Request)

func (*JobHandler) Patch added in v0.0.4

func (h *JobHandler) Patch(w http.ResponseWriter, r *http.Request)

func (*JobHandler) Routes

func (h *JobHandler) Routes() chi.Router

type JobLifecycle

type JobLifecycle interface {
	CompleteJob(jobID string, result JobCompletion)
	UnregisterJob(jobID string)
	CleanupTaskWindow(taskID string)
	StopJobRuntime(runtimeID string)
	// SignalJobRuntime delivers a single Unix signal to the runtime's process
	// group. Phase 3-b uses it to graceful-stop the agent (SIGUSR1) without
	// tearing down the surrounding sandbox runtime: claude.Adapter.Run() has a
	// signal.Notify(SIGUSR1) handler that translates the group signal into a
	// SIGTERM toward the claude child, then normalises the resulting exit
	// status into Result.StoppedByDaemon=true.
	SignalJobRuntime(runtimeID string, sig syscall.Signal)
}

type JobListFilter

type JobListFilter struct {
	Status       string
	Interactive  *bool // nil = no filter
	TasklessOnly bool  // true = only jobs where task_id IS NULL
}

JobListFilter specifies optional filters for global job listing.

type JobLogReader

type JobLogReader interface {
	ReadJobLog(runtimeID string) ([]byte, error)
	StatJobLog(runtimeID string) (size int64, mtime time.Time, err error)
}

JobLogReader reads the transcript log for a given runtime.

type JobLogSSEHandler

type JobLogSSEHandler struct {
	Subscriber dispatcher.RuntimeSubscriber
	Registry   *auth.ConnectionRegistry
}

JobLogSSEHandler streams live job output as Server-Sent Events on GET /{id}/log?follow=true.

func (*JobLogSSEHandler) ServeHTTP

func (h *JobLogSSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

type JobStatus

type JobStatus string
const (
	JobStatusRunning   JobStatus = "running"
	JobStatusCompleted JobStatus = "completed"
	JobStatusFailed    JobStatus = "failed"
)

type JobStore

type JobStore interface {
	GetJob(id string) (*Job, error)
	ListJobsByTask(taskID string) ([]*Job, error)
	UpdateJob(job *Job) error
}

type JobWithContext

type JobWithContext struct {
	Job
	TaskTitle   string `json:"task_title"`
	ProjectName string `json:"project_name"`
}

JobWithContext extends Job with task and project metadata for the TUI global view.

type LoginHandler

type LoginHandler struct {
	Pairing loginPairing
	Signer  loginSigner
	Store   loginDeviceStore
	Limiter loginRateLimiter
}

LoginHandler handles /login and /auth.

func (*LoginHandler) GetAuth

func (h *LoginHandler) GetAuth(w http.ResponseWriter, r *http.Request)

func (*LoginHandler) GetLogin

func (h *LoginHandler) GetLogin(w http.ResponseWriter, r *http.Request)

func (*LoginHandler) PostLogin

func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request)

type MetaStore

type MetaStore interface {
	Get(id string) (*orchestrator.ProjectMeta, bool)
	// GetWithWorkspace returns the project meta with workspace.yaml (kits,
	// env, capabilities) hydrated in. Use this whenever the caller dispatches
	// hooks or otherwise needs the resolved runtime view.
	GetWithWorkspace(ctx context.Context, projectID string) (*orchestrator.ProjectMeta, error)
}

type Notifier

type Notifier interface {
	Notify(ctx context.Context, ev notify.Event) error
}

Notifier sends an agent-driven notification for a task. Implementations typically exec a user-configured command. nil-safe at the call site: TaskAppService.NotifyTask returns an error when Notify is unset.

type NotifyTaskRequest

type NotifyTaskRequest struct {
	Message    string `json:"message"`
	Ask        string `json:"ask,omitempty"`
	QuestionID string `json:"question_id,omitempty"`
	Progress   string `json:"progress,omitempty"`
	Done       string `json:"done,omitempty"`
	Fail       string `json:"fail,omitempty"`
}

type Pairer

type Pairer interface {
	Issue(ctx context.Context, label string) (string, error)
}

WebManagementHandler serves the CLI management API at /api/web/*. All routes are accessible only via UNIX socket (CLI control plane). Pairer issues pairing codes.

type ProjectAppService

type ProjectAppService struct {
	Projects ProjectRepository
	Meta     interface {
		Load(workDir string) (*orchestrator.ProjectMeta, error)
		Get(id string) (*orchestrator.ProjectMeta, bool)
		Remove(id string)
		LoadAll(projects []*orchestrator.Project) []error
		SetWorkspaceID(projectID, workspaceID string)
	}
	// Hydrator is optional. When set, callers that need fully-resolved meta
	// (workspace-level capabilities / host_commands / env merged in +
	// SecretNamespace injected) go through GetWithWorkspace instead of the
	// bare Meta.Get path.
	// Wired in internal/server/wire.go alongside the workspace store.
	Hydrator orchestrator.MetaHydrator
	// CaptureUpstreamURL reads a project work_dir's git origin remote and
	// normalizes it to HTTPS (docs/plans/git-gateway-cutover.md PR2). Wired
	// in internal/server/wire.go to dispatcher.CaptureUpstreamURL. When nil
	// (e.g. in tests that do not exercise this path), upstream_url capture
	// is skipped entirely rather than panicking.
	CaptureUpstreamURL func(workDir string) (string, error)
	// Workspaces provides direct CRUD over a single workspace's
	// WorkspaceMeta (docs/plans/workspace-db-consolidation.md PR4). Wired in
	// internal/server/wire.go to ProjectStore.WorkspaceStore(). Required for
	// the workspace create/show/update/remove endpoints; callers that never
	// exercise those (most existing tests) can leave it nil.
	Workspaces WorkspaceStore
	// HostCommands, when set, returns the daemon's live aggregated
	// host_commands snapshot (name -> spec). CreateWorkspace/UpdateWorkspace
	// validate every meta.HostCommands reference against this snapshot,
	// rejecting an unknown name with 400 (docs/plans/
	// workspace-db-consolidation.md MAJOR 2, codex review: an unresolvable
	// reference was previously persisted silently and only warned-about +
	// skipped at dispatch time). Wired in internal/server/wire.go to
	// Server.HostCommands. nil skips the validation entirely — tests that do
	// not exercise it can leave this unset, same convention as
	// CaptureUpstreamURL/Hydrator above.
	HostCommands func() map[string]orchestrator.HostCommandSpec
	// contains filtered or unexported fields
}

func (*ProjectAppService) CreateProject

func (s *ProjectAppService) CreateProject(workDir string) (*orchestrator.Project, error)

func (*ProjectAppService) CreateWorkspace added in v0.0.13

func (s *ProjectAppService) CreateWorkspace(slug string, meta *orchestrator.WorkspaceMeta) (*WorkspaceDetail, error)

CreateWorkspace inserts a brand-new workspace at slug (docs/plans/workspace-db-consolidation.md PR4 Step C, POST /api/workspaces). See the ProjectService interface doc comment for the status code contract.

func (*ProjectAppService) DeleteProject

func (s *ProjectAppService) DeleteProject(id string) error

func (*ProjectAppService) ExportWorkspace added in v0.0.13

func (s *ProjectAppService) ExportWorkspace(slug string) ([]byte, string, error)

ExportWorkspace returns slug's raw yaml body (the marshaled WorkspaceMeta with a top-level "slug:" key inlined) and its current revision (docs/plans/workspace-db-consolidation.md PR5 Step A).

The body shape mirrors CreateWorkspace / ImportWorkspace's input shape exactly — same top-level "slug:" key alongside the meta fields, decoded by the same DecodeWorkspaceCreateStrict — so `boid workspace export` can pipe straight into `boid workspace import` (or the API can round-trip export → POST /api/workspaces/import) without any translation step (codex PR5 review, MAJOR: round-trip 非対称は避ける、 CLI --slug は override 用の便宜として残す)。 An earlier iteration deliberately omitted "slug:" from the body — "the slug is already in the URL" — but that meant the exported yaml was NOT a valid import body, which is exactly the shape a round-trip needs.

Meta and revision are read from a single atomic snapshot (LoadWithRevision), the same discipline GetWorkspace's MAJOR 1 fix established — see that method's doc comment for why a separate meta/ revision query pair would risk straddling a concurrent write.

func (*ProjectAppService) GetProject

func (s *ProjectAppService) GetProject(id string) (*orchestrator.Project, error)

func (*ProjectAppService) GetWorkspace added in v0.0.13

func (s *ProjectAppService) GetWorkspace(slug string) (*WorkspaceDetail, error)

GetWorkspace returns slug's meta, revision, and assigned project ids (docs/plans/workspace-db-consolidation.md PR4 Step D, GET /api/workspaces/{slug}).

MAJOR 1 (codex review): meta and revision are read from a single atomic snapshot (Workspaces.LoadWithRevision) rather than two separate queries (a plain Load for meta, then GetWorkspaceSummary for revision) — the pre-fix two-query approach could straddle a concurrent PUT and return a meta/revision pair that never coexisted in the DB.

func (*ProjectAppService) ImportWorkspace added in v0.0.13

func (s *ProjectAppService) ImportWorkspace(slug string, meta *orchestrator.WorkspaceMeta, mode string) (*WorkspaceDetail, error)

ImportWorkspace inserts (mode=create-only) or upserts (mode=replace) slug's workspace meta from a yaml import body (docs/plans/ workspace-db-consolidation.md PR5 Step C, POST /api/workspaces/import). The body shape mirrors CreateWorkspace's (slug inlined in the yaml, decoded by the same DecodeWorkspaceCreateStrict the handler calls before this runs) — mode=create-only reuses exactly the same Workspaces.Create path CreateWorkspace does (409 on an existing slug); mode=replace reuses Workspaces.Save, which is upsert-on-conflict (see orchestrator.WorkspaceRepository.Save's doc comment), so it both creates a missing slug and overwrites an existing one wholesale — a true upsert, unlike UpdateWorkspace's PUT semantics which 404 on a missing slug.

mode is validated here, not only in the handler (the same belt-and-suspenders duplication ValidWorkspaceSlug gets throughout this file): an unrecognized mode is rejected with 400 before anything is materialized or persisted, so a caller invoking ImportWorkspace directly (bypassing WorkspaceHandler) still gets the same guarantee.

func (*ProjectAppService) ListProjects

func (s *ProjectAppService) ListProjects(workspaceID string) ([]*orchestrator.Project, error)

func (*ProjectAppService) ListWorkspaces

func (s *ProjectAppService) ListWorkspaces() ([]*orchestrator.WorkspaceSummary, error)

func (*ProjectAppService) ReloadProjects

func (s *ProjectAppService) ReloadProjects() (*ProjectReloadResult, error)

func (*ProjectAppService) RemoveWorkspace added in v0.0.13

func (s *ProjectAppService) RemoveWorkspace(slug string) error

RemoveWorkspace deletes slug (docs/plans/workspace-db-consolidation.md PR4 Step F, DELETE /api/workspaces/{slug}). The reserved default workspace cannot be removed (decision 8); any project still assigned to slug is re-pointed at default by orchestrator.WorkspaceRepository.Remove's transaction, so this never leaves a dangling project_workspaces reference at the DB layer.

That DB-level transaction has no way to reach into the daemon's in-memory ProjectStore cache (s.Meta), though — so without the snapshot-then-sync dance below, every project that *was* assigned to slug would keep serving GetWithWorkspace hydration off a stale cached workspace_id that no longer has a corresponding workspaces row, silently degrading (host_commands/env/ capabilities not injected, same as the pre-existing "workspace.yaml not found" degraded-mode path) until the next daemon restart. Snapshotting the assigned project ids *before* the delete (list ordering matters: after the delete, ListWorkspaces/ListProjects would already report them as belonging to default and we'd have nothing left to distinguish "was reassigned, needs a cache sync" from "already was on default") then calling s.Meta.SetWorkspaceID for each afterward mirrors the exact same cache update SetProjectWorkspace already performs for a single explicit assignment.

MAJOR 3 (codex review): the whole snapshot -> DB remove -> cache-sync sequence below runs under s.mu (see that field's doc comment), serialized against SetProjectWorkspace's own critical section. Without this, a concurrent SetProjectWorkspace reassigning one of the snapshotted projects to a *different*, still-existing workspace could have its cache write clobbered by this method's stale-snapshot loop landing after it — leaving the in-memory cache pointing at "default" while the DB (and any fresh GetWithWorkspace hydration) says the project belongs to the new workspace.

func (*ProjectAppService) ResolveProjectRef

func (s *ProjectAppService) ResolveProjectRef(ref string) ([]*orchestrator.Project, error)

ResolveProjectRef resolves ref to matching projects with the following priority:

  1. id exact match (returns immediately on first hit)
  2. name exact match (all projects with that name)
  3. name substring match, case-insensitive

Returns a single-element slice on unambiguous match, a multi-element slice on ambiguous match, or StatusError{404} when nothing matches.

func (*ProjectAppService) SetProjectWorkspace

func (s *ProjectAppService) SetProjectWorkspace(id, workspaceID string) (*orchestrator.Project, error)

func (*ProjectAppService) UpdateWorkspace added in v0.0.13

func (s *ProjectAppService) UpdateWorkspace(slug string, meta *orchestrator.WorkspaceMeta, ifMatch string, force bool) (*WorkspaceDetail, error)

UpdateWorkspace replaces slug's meta wholesale (docs/plans/ workspace-db-consolidation.md PR4 Step E, PUT /api/workspaces/{slug}), enforcing optimistic concurrency: ifMatch must equal the workspace's current revision unless force is true (decision 17 — PUT + If-Match, no PATCH). A missing ifMatch (and !force) is rejected with 428 Precondition Required rather than silently proceeding, since accepting an unconditional PUT by default would make the ETag check opt-in instead of the intended opt-out (--force).

MAJOR 1 (codex review): the non-force path no longer reads the current revision and then Saves unconditionally as two separate statements — that window let two concurrent PUTs starting from the same If-Match both pass their check before either had written, silently losing one writer's update, and let a DELETE landing between a GET and a subsequent PUT resurrect the workspace via Save's upsert semantics. Instead, Workspaces.UpdateIfRevisionMatches performs the check-and-write as one atomic UPDATE; matched=false is then disambiguated into 404 (slug is truly gone) vs 412 (slug exists, but ifMatch is stale) with a follow-up existence check — that follow-up races nothing of consequence, since it only picks the HTTP status code for an already-failed write, not any data mutation.

force=true intentionally keeps the old "check existence, then unconditional Save" shape: force explicitly opts out of the revision guarantee (last-write-wins), so there is no CAS invariant left to protect — but Save is upsert-on-conflict, so a plain Save would silently *create* slug if it did not already exist, which PUT (unlike POST) must not do; hence the explicit existence check before it.

type ProjectHandler

type ProjectHandler struct {
	Service           ProjectService
	SessionDispatcher SessionDispatcher // optional; nil disables the start-session endpoint
	ExecDispatcher    ExecDispatcher    // optional; nil disables the exec endpoint
}

func (*ProjectHandler) Create

func (h *ProjectHandler) Create(w http.ResponseWriter, r *http.Request)

func (*ProjectHandler) Delete

func (h *ProjectHandler) Delete(w http.ResponseWriter, r *http.Request)

func (*ProjectHandler) Get

func (*ProjectHandler) List

func (*ProjectHandler) Reload

func (h *ProjectHandler) Reload(w http.ResponseWriter, r *http.Request)

func (*ProjectHandler) Routes

func (h *ProjectHandler) Routes() chi.Router

func (*ProjectHandler) SetWorkspace

func (h *ProjectHandler) SetWorkspace(w http.ResponseWriter, r *http.Request)

func (*ProjectHandler) StartExec added in v0.0.10

func (h *ProjectHandler) StartExec(w http.ResponseWriter, r *http.Request)

StartExec handles POST /api/projects/{id}/exec. The project is resolved from the URL ref the same way every other project route does; the body specifies argv and the optional readonly / interactive / display_name knobs. Unlike StartSession there is no top-level /api/exec — every exec is project-scoped by construction (`boid exec -p <ref> -- argv...`).

func (*ProjectHandler) StartSession added in v0.0.6

func (h *ProjectHandler) StartSession(w http.ResponseWriter, r *http.Request)

StartSession handles POST /api/projects/{id}/sessions. The project is resolved from the URL ref (so refs like a name or short id work the same way other project routes do); the body specifies harness_type and the optional knobs (instruction / readonly / model / session_id / display_name).

type ProjectReloadResult

type ProjectReloadResult struct {
	Status string   `json:"status"`
	Errors []string `json:"errors,omitempty"`
}

type ProjectRepository

type ProjectRepository interface {
	CreateProject(project *orchestrator.Project) error
	GetProject(id string) (*orchestrator.Project, error)
	ListProjects() ([]*orchestrator.Project, error)
	SetProjectWorkspace(projectID, workspaceID string) error
	ListWorkspaces() ([]*orchestrator.WorkspaceSummary, error)
	DeleteProject(id string) error
	// SetProjectUpstreamURL persists a project's captured upstream_url (see
	// docs/plans/git-gateway-cutover.md PR2). Used by ReloadProjects'
	// recapture and by the daemon-startup backfill.
	SetProjectUpstreamURL(projectID, upstreamURL string) error
	// WorkspaceExists reports whether workspaceID refers to an existing
	// workspaces table row. Used by ProjectAppService.SetProjectWorkspace to
	// reject assignment to a nonexistent slug (docs/plans/
	// workspace-db-consolidation.md MAJOR 5 codex review fix).
	WorkspaceExists(workspaceID string) (bool, error)
	// AssignWorkspaceIfExists atomically checks-then-assigns in a single DB
	// transaction (docs/plans/workspace-db-consolidation.md MAJOR 3, codex
	// review), replacing the WorkspaceExists+SetProjectWorkspace two-step
	// ProjectAppService.SetProjectWorkspace used before: a DELETE landing
	// between those two separate calls could leave a dangling
	// project_workspaces reference. Returns an error wrapping os.ErrNotExist
	// when workspaceID has no corresponding row (DefaultWorkspaceSlug and ""
	// are exempt from the check — see the implementation's doc comment).
	AssignWorkspaceIfExists(projectID, workspaceID string) error
	// GetWorkspaceSummary returns a single workspace's project count and
	// revision, or an error wrapping os.ErrNotExist. Used by the workspace
	// CRUD handlers (docs/plans/workspace-db-consolidation.md PR4) to build
	// responses and to read the current revision for the PUT If-Match check.
	GetWorkspaceSummary(slug string) (*orchestrator.WorkspaceSummary, error)
}

type ProjectService

type ProjectService interface {
	CreateProject(workDir string) (*orchestrator.Project, error)
	ListProjects(workspaceID string) ([]*orchestrator.Project, error)
	ListWorkspaces() ([]*orchestrator.WorkspaceSummary, error)
	GetProject(id string) (*orchestrator.Project, error)
	SetProjectWorkspace(id, workspaceID string) (*orchestrator.Project, error)
	DeleteProject(id string) error
	ReloadProjects() (*ProjectReloadResult, error)
	// ResolveProjectRef resolves a ref string to one or more matching projects.
	// Priority: id exact match > name exact match > name substring match (case-insensitive).
	// Returns 1 project on unambiguous match, multiple on ambiguous match, StatusError{404} on no match.
	ResolveProjectRef(ref string) ([]*orchestrator.Project, error)

	// CreateWorkspace inserts a brand-new workspace (docs/plans/
	// workspace-db-consolidation.md PR4, POST /api/workspaces). Returns a
	// *StatusError{409} when slug already has a row, {400} for an invalid
	// slug.
	CreateWorkspace(slug string, meta *orchestrator.WorkspaceMeta) (*WorkspaceDetail, error)
	// GetWorkspace returns slug's meta, revision, and assigned project ids
	// (GET /api/workspaces/{slug}). *StatusError{404} when slug is unknown.
	GetWorkspace(slug string) (*WorkspaceDetail, error)
	// UpdateWorkspace replaces slug's meta wholesale (PUT
	// /api/workspaces/{slug}), enforcing optimistic concurrency via ifMatch
	// against the workspace's current revision unless force is true.
	// *StatusError{428} missing ifMatch, {412} stale ifMatch, {404} unknown
	// slug.
	UpdateWorkspace(slug string, meta *orchestrator.WorkspaceMeta, ifMatch string, force bool) (*WorkspaceDetail, error)
	// RemoveWorkspace deletes slug (DELETE /api/workspaces/{slug}).
	// *StatusError{400} for the reserved default slug, {404} unknown slug.
	RemoveWorkspace(slug string) error

	// ExportWorkspace returns slug's raw yaml body (the marshaled
	// WorkspaceMeta, deliberately with no top-level "slug" key — the slug is
	// already known from the URL this backs) and its current revision (GET
	// /api/workspaces/{slug}/export, docs/plans/workspace-db-consolidation.md
	// PR5 Step A). *StatusError{404} when slug is unknown.
	ExportWorkspace(slug string) (yamlBytes []byte, revision string, err error)
	// ImportWorkspace inserts (mode="create-only") or upserts
	// (mode="replace") slug's workspace meta from an import body (POST
	// /api/workspaces/import?mode=..., PR5 Step B/C). Unlike
	// CreateWorkspace/UpdateWorkspace, mode="replace" is a true upsert: it
	// creates slug if absent and overwrites it wholesale (last-write-wins,
	// no If-Match) if present. *StatusError{409} for mode="create-only"
	// against an existing slug, {400} for an invalid slug, an unrecognized
	// mode value, or an unknown host_commands reference.
	ImportWorkspace(slug string, meta *orchestrator.WorkspaceMeta, mode string) (*WorkspaceDetail, error)
}

type ProjectWorkDirLookup

type ProjectWorkDirLookup interface {
	GetProject(id string) (*orchestrator.Project, error)
}

ProjectWorkDirLookup provides read access to a project's working directory.

type ReopenTaskRequest

type ReopenTaskRequest struct {
	Message string `json:"message,omitempty"`
}

type ReplayHookRequest

type ReplayHookRequest struct {
	HookID string
	Status string // optional: override task.Status before replay
}

ReplayHookRequest is the input for hook replay.

type ReplayHookResult

type ReplayHookResult struct {
	Task        *orchestrator.Task        `json:"task"`
	FiredEvents []orchestrator.FiredEvent `json:"fired_events,omitempty"`
}

ReplayHookResult is the output of a hook replay.

type RerunTaskRequest

type RerunTaskRequest struct {
	AutoStart            bool            `json:"auto_start,omitempty"`
	InstructionsOverride json.RawMessage `json:"instructions_override,omitempty"`
}

type SecretHandler

type SecretHandler struct {
	Store SecretStore
}

func (*SecretHandler) Delete

func (h *SecretHandler) Delete(w http.ResponseWriter, r *http.Request)

func (*SecretHandler) GetValue

func (h *SecretHandler) GetValue(w http.ResponseWriter, r *http.Request)

func (*SecretHandler) List

func (h *SecretHandler) List(w http.ResponseWriter, r *http.Request)

func (*SecretHandler) Routes

func (h *SecretHandler) Routes() chi.Router

func (*SecretHandler) Set

type SecretStore

type SecretStore interface {
	List(namespace string) ([]string, error)
	Set(namespace, key, value string) error
	Delete(namespace, key string) error
	Get(namespace, key string) (string, error)
}

type SessionDispatcher added in v0.0.6

type SessionDispatcher interface {
	StartSession(ctx context.Context, req StartSessionRequest) (*StartSessionResult, error)
}

SessionDispatcher launches a session job (claude / codex / opencode under a HarnessAdapter) and returns the runtime job id.

type SessionHandler added in v0.0.6

type SessionHandler struct {
	Service    ProjectService
	Dispatcher SessionDispatcher
}

SessionHandler exposes the POST /api/sessions surface (and the project-scoped /api/projects/{id}/sessions variant mounted by ProjectHandler). Phase 3-d (PR1) introduced sessions as a first-class JobKind alongside hook and exec so user-initiated agent runs (WebUI [New Session] / `boid agent`) no longer have to piggyback on the project command path.

func (*SessionHandler) Routes added in v0.0.6

func (h *SessionHandler) Routes() chi.Router

func (*SessionHandler) Start added in v0.0.6

func (h *SessionHandler) Start(w http.ResponseWriter, r *http.Request)

Start handles POST /api/sessions. The request body must specify project_id. Use the project-scoped variant when the project is implied by the URL.

Session-id resume was removed: every Start launches a fresh agent process, so there is no `/api/sessions/{id}/resume` companion route anymore.

type SetProjectWorkspaceRequest

type SetProjectWorkspaceRequest struct {
	WorkspaceID string `json:"workspace_id"`
}

type StartExecRequest added in v0.0.10

type StartExecRequest struct {
	// ProjectID is taken from the URL for the project-scoped route; there is
	// no top-level /api/exec (every exec is inherently project-scoped —
	// `boid exec -p <ref> -- argv...`), so the handler always fills this in
	// from chi.URLParam before it reaches the dispatcher.
	ProjectID string `json:"project_id"`

	// Argv is the literal program + arguments to run inside the sandbox.
	// Required, non-empty.
	Argv []string `json:"argv"`

	// Readonly, when true, mounts the project workspace read-only. Exec
	// defaults to writable, matching the CLI's --readonly flag default.
	Readonly bool `json:"readonly,omitempty"`

	// Interactive requests a PTY-backed sandbox. The CLI computes this from
	// isatty(stdin) && isatty(stdout) (see cmd/exec.go) — both, not stdin
	// alone, because a PTY is only correct when the whole terminal round-trip
	// is real; `boid exec -- cmd | grep pattern` must NOT get a PTY even
	// though its own stdin is a real terminal, or the PTY's line-discipline
	// framing would corrupt the piped bytes grep receives. false selects the
	// plain-pipe transport (see runtime_local_linux.go's non-interactive
	// branch and its StdinForward stdin-piping addition).
	Interactive bool `json:"interactive,omitempty"`

	// DisplayName is the human-readable label persisted to jobs.display_name.
	// Empty falls back to argv[0] (dispatcher.BuildExecJobSpec).
	DisplayName string `json:"display_name,omitempty"`
}

StartExecRequest is the body of POST /api/projects/{id}/exec. `boid exec` used to be a client-side-only path (the CLI built its own SandboxRuntimeInfo and syscall.Exec'd straight into the sandbox launcher), which is exactly why it never picked up the git gateway cutover's Dispatch()-only wiring (registerGatewayToken / GatewayURL / GatewayCloneURL) — see docs/plans/git-gateway-cutover.md. This request type is the daemon-side entry point that routes exec through the same Runner.Dispatch() path as every session, so any future dispatch-time wiring lands on both by construction instead of needing a second, easy-to-forget call site.

Unlike sessions (fixed harness_type, agent-driven argv), exec runs an arbitrary user-supplied argv with no HarnessAdapter agent — see dispatcher.BuildExecJobSpec, which forces HarnessType="shell" underneath.

type StartExecResult added in v0.0.10

type StartExecResult struct {
	JobID     string `json:"job_id"`
	AttachURL string `json:"attach_url"`
}

StartExecResult is the response shape for POST /api/projects/{id}/exec.

type StartSessionRequest added in v0.0.6

type StartSessionRequest struct {
	// ProjectID names the project whose traits the session inherits. For the
	// project-scoped route (`/api/projects/{id}/sessions`) it is taken from
	// the URL and the body field is ignored.
	ProjectID string `json:"project_id"`

	// HarnessType selects the agent adapter. Must be one of "claude",
	// "codex", or "opencode". The historical "shell" session variant was
	// retired after the git gateway cutover — `boid exec -p <project> -- bash`
	// runs the shell adapter through the same Runner.Dispatch() with an
	// interactive PTY, so there is no session use case left for it.
	// sessionDispatcherAdapter.StartSession rejects any other value at the
	// API boundary.
	HarnessType string `json:"harness_type"`

	// Instruction is the optional bootstrap prompt for the first turn. Empty
	// leaves the harness to pick its default (no positional for session mode
	// on claude, since /boid-task is meaningless without a task.yaml).
	Instruction string `json:"instruction,omitempty"`

	// Readonly, when true, mounts the project workspace read-only. Sessions
	// default to writable (developer ergonomics > fail-safety).
	Readonly bool `json:"readonly,omitempty"`

	// Model overrides the harness binary's default model selection.
	Model string `json:"model,omitempty"`

	// DisplayName is the human-readable session label persisted to
	// jobs.display_name. Empty falls back to "<harness> session".
	DisplayName string `json:"display_name,omitempty"`
}

StartSessionRequest is the body of POST /api/sessions and POST /api/projects/{id}/sessions. Phase 3-d (PR1) introduced the session concept as a first-class JobKind so the WebUI [New Session] dialog and the `boid agent` CLI share one daemon entry point.

type StartSessionResult added in v0.0.6

type StartSessionResult struct {
	JobID     string `json:"job_id"`
	AttachURL string `json:"attach_url"`
}

StartSessionResult is the response shape for POST /api/sessions and the project-scoped variant.

type StatusError

type StatusError struct {
	Code    int
	Message string
}

func (*StatusError) Error

func (e *StatusError) Error() string

type TaskAnswerService

type TaskAnswerService interface {
	AnswerTask(ctx context.Context, taskID, questionID, answer string) error
}

TaskAnswerService records a user reply to a pending Q&A question and transitions the task back to executing.

type TaskAppService

type TaskAppService struct {
	Tasks       TaskStore
	Actions     ActionStore
	Jobs        JobStore
	Meta        MetaStore
	Workflow    WorkflowService
	Projects    ProjectWorkDirLookup
	RuntimesDir string
	Notify      Notifier
	// BlockingAsk coordinates harness-independent blocking Q&A (boid task ask).
	// Shared with the sandbox boid builtin executor (which calls AskTaskBlocking)
	// and the answer path (AnswerTask), so both halves of a blocking ask use the
	// same in-memory registry. Nil disables blocking ask (notify --ask still works).
	BlockingAsk *BlockingAskRegistry
	// AskDisconnectGrace is how long an awaiting task may sit with no live agent
	// parked before the daemon reclaims it (a blocking ask whose foreground
	// command was killed by a harness command-timeout). Zero falls back to
	// defaultAskDisconnectGrace.
	AskDisconnectGrace time.Duration
}

func (*TaskAppService) AnswerTask

func (s *TaskAppService) AnswerTask(ctx context.Context, taskID, questionID, answer string) error

AnswerTask saves the user's reply and transitions the task awaiting → executing.

func (*TaskAppService) AskTaskBlocking added in v0.0.6

func (s *TaskAppService) AskTaskBlocking(ctx context.Context, taskID, question string) (string, error)

AskTaskBlocking implements the harness-independent blocking Q&A RPC behind `boid task ask <question>`. The agent stays alive and blocks inside this call until the user/supervisor answers, so the round-trip works for every harness uniformly — there is no session-resume path anywhere in boid (the legacy `notify --ask` → `claude --resume` flow was removed).

The call is disconnect-resilient and idempotent across re-asks. Harnesses kill long-running shell commands on a command-timeout (claude-code / opencode at ~120s), which severs the held connection. When that happens the model retries the identical `boid task ask`; this handler recognises the retry and recovers instead of treating it as a fresh question:

  • executing → fresh ask: register the answer channel, transition executing → awaiting via ApplyAction("ask"), fire the user notification (root tasks only; child tasks are seen by their supervisor's monitor loop), then block in Wait.
  • awaiting → re-ask: if an answer was parked durably while the agent was disconnected (PendingAnswer), consume it immediately and flip back to executing. Otherwise re-attach to the SAME question id and block again — no second "ask" transition, no new notification.

Register happens before the awaiting transition so an answer that races in immediately afterwards is never dropped. Decision B1 (relaxed): a concurrent ask carrying a DIFFERENT question id still fails with ErrAskPending; a re-ask of the same question is a re-attach and is allowed.

func (*TaskAppService) CreateTask

func (s *TaskAppService) CreateTask(req CreateTaskRequest) (*orchestrator.Task, error)

func (*TaskAppService) DeleteTask

func (s *TaskAppService) DeleteTask(id string, force bool) error

func (*TaskAppService) DuplicateTask

func (s *TaskAppService) DuplicateTask(sourceID string, autoStart bool) (*orchestrator.Task, error)

func (*TaskAppService) GetInstructions added in v0.0.13

func (s *TaskAppService) GetInstructions(id string) ([]orchestrator.RoutedInstruction, error)

GetInstructions returns the task's "active" routed instruction — the entry orchestrator.CurrentInstructions derives from the last entry in the task's instruction history.

NOT the `boid task instructions` RPC's data source (fixed in codex review on PR #797 before merge — see wiring-seams.md #13 and internal/server/boid_executor.go's BoidOpTaskInstructions case): a task row has no notion of "which job is asking", but orchestrator.Evaluator can fire two agent-kind hooks for different agents from the same task in one round (any agent appearing anywhere in the instruction history matches, not just the active/last entry) — GetInstructions would hand a claude job's RPC call the codex hook's instruction whenever codex happens to be the most recent history entry, and vice versa. Kept as a task-row-level projection for callers that genuinely want "the task's current routing state" independent of any specific job (there are none yet in this codebase; a plausible future one is a Web UI task-detail view). Returns an empty (non-nil) slice rather than nil, even when the task carries no active instruction (not executing, or no instructions history yet).

func (*TaskAppService) GetInstructionsField added in v0.0.13

func (s *TaskAppService) GetInstructionsField(id, path string) (string, error)

GetInstructionsField resolves a dotted path against the []RoutedInstruction list returned by GetInstructions, mirroring GetTaskField's --field contract.

func (*TaskAppService) GetTask

func (s *TaskAppService) GetTask(id string) (*orchestrator.Task, error)

func (*TaskAppService) GetTaskCurrent added in v0.0.13

func (s *TaskAppService) GetTaskCurrent(id string) (*orchestrator.TaskSnapshot, error)

GetTaskCurrent returns the task's business-metadata snapshot — the same subset historically materialized at $HOME/.boid/context/task.yaml (orchestrator.SnapshotTask), now also the payload of `boid task current`. Unlike the file (frozen at dispatch time), this re-derives from the task row on every call, so it reflects concurrent `task update` calls.

func (*TaskAppService) GetTaskCurrentField added in v0.0.13

func (s *TaskAppService) GetTaskCurrentField(id, path string) (string, error)

GetTaskCurrentField resolves a dotted path against the task-current snapshot, mirroring GetTaskField's semantics for `--field` (missing path → "", scalar → unquoted/stringified, object/array → compact JSON).

func (*TaskAppService) GetTaskDetail

func (s *TaskAppService) GetTaskDetail(id string) (*TaskDetailView, error)

func (*TaskAppService) GetTaskField

func (s *TaskAppService) GetTaskField(id, path string) (string, error)

GetTaskField resolves a dotted field path against the task. See ResolveTaskField for the path syntax (top-level fields, payload traits, computed lifecycle).

func (*TaskAppService) ImportTasks

func (s *TaskAppService) ImportTasks(reqs []CreateTaskRequest) (*ImportResult, error)

func (*TaskAppService) ListTasks

func (s *TaskAppService) ListTasks(filter orchestrator.TaskFilter) ([]*orchestrator.Task, error)

func (*TaskAppService) NotifyTask

func (s *TaskAppService) NotifyTask(ctx context.Context, taskID, message, ask, questionID, progress, done, fail string) error

NotifyTask invokes the configured notify command for the given task. Returns 501 when no notifier is wired and ask is empty (notifications disabled in config). When ask is non-empty the task is transitioned to awaiting; the notification is best-effort and skipped if no notifier is configured. questionID identifies the Q&A turn (generated when empty). When progress is non-empty (progress mode), no hook fires and no state transition occurs — only a progress Action is written to the timeline. ask and progress are mutually exclusive.

Note: ask transitions the task to awaiting but does NOT spawn a resume dispatch when the user replies. The session-id resume path was removed (every dispatch is a fresh agent process); only `boid task ask` (the blocking RPC) can deliver an answer back to a live agent.

func (*TaskAppService) RerunTask

func (s *TaskAppService) RerunTask(id string, req RerunTaskRequest) (*orchestrator.Task, error)

func (*TaskAppService) UpdateTask

func (s *TaskAppService) UpdateTask(id string, req UpdateTaskRequest) (*orchestrator.Task, error)

func (*TaskAppService) UpdateTaskPayloadPatch added in v0.0.13

func (s *TaskAppService) UpdateTaskPayloadPatch(jobID string, patch json.RawMessage, allowedTraits []orchestrator.TraitType) (*orchestrator.Task, error)

UpdateTaskPayloadPatch applies patch to the task owning jobID using the SAME merge semantics the file-based payload_patch.json → job_done → Coordinator pipeline has always applied (orchestrator.MergePayloadPatch, gated by the trait allowlist the firing hook itself declares via Traits.Produces) — see orchestrator/coordinator.go's HandlerResult.allowedTraits and wiring-seams.md #13/#17. This is deliberately NOT UpdateTask's simpler top-level shallow merge (used by --payload-file): UpdateTask has no notion of a "firing hook", so it can't reproduce this gate.

jobID (not taskID) is the identity this method resolves from, because the allowedTraits gate is keyed off the CALLING job's own HandlerID — the specific hook that was dispatched to produce this job, which may differ from other jobs the same task has had or will have (mirrors why BoidOpTaskInstructions/Env/Payload are JobID-scoped, not TaskID-scoped).

allowedTraits is supplied by the CALLER — it must be the value captured AT DISPATCH TIME (dispatcher.JobContextSnapshot.PayloadPatchAllowedTraits, itself sourced from orchestrator.JobSpec.HookTraitsProduces), never re-derived here from a live project-meta lookup. An earlier version of this method did its own live lookup (GetTask's ProjectID -> current meta -> current behavior -> hook by HandlerID) and codex review caught the TOCTOU staleness bug that creates: if project.yaml is edited/reloaded between dispatch and this call, a live lookup can either apply the WRONG (post-edit) trait list or silently fall back to unrestricted when the hook can no longer be found by name — neither matches what was actually authorized when the job was dispatched. Accepting the dispatch-time value as a parameter makes that class of staleness structurally impossible instead of requiring a "fail closed on lookup failure" special case (see wiring-seams.md #17's Major 1 finding). nil means unrestricted — see JobSpec.HookTraitsProduces's own doc comment for exactly when that's the correct (not just the fallback) value.

The GetTask -> MergePayloadPatch -> UpdateTask sequence below is serialized per task id (payloadPatchLockFor) so two concurrent calls for the same task — e.g. two hooks in the same readonly task's parallel dispatch round, each patching a different trait — cannot race a read-modify-write and silently lose one of their writes (Phase 5b PR7 codex review Blocker 2, wiring-seams.md #17).

type TaskDetailView

type TaskDetailView struct {
	Task             *orchestrator.Task
	Actions          []*orchestrator.Action
	Jobs             []*Job
	AvailableActions []string `json:"available_actions"`
}

type TaskEvent

type TaskEvent struct {
	Kind    string // "action" / "job" / "fired_event" etc.
	Payload any
}

TaskEvent はタスクに関連するイベントを表す。

type TaskEventHub

type TaskEventHub struct {
	// contains filtered or unexported fields
}

TaskEventHub は taskID ごとに複数の subscriber へイベントを配送する pub-sub ハブ。

func NewTaskEventHub

func NewTaskEventHub() *TaskEventHub

NewTaskEventHub は新しい TaskEventHub を返す。

func (*TaskEventHub) Broadcast

func (h *TaskEventHub) Broadcast(taskID string, ev TaskEvent)

Broadcast は taskID の全 subscriber に ev を非ブロッキングで配送する。 チャネルが満杯の subscriber はドロップする(hub 全体を止めない)。

func (*TaskEventHub) Subscribe

func (h *TaskEventHub) Subscribe(ctx context.Context, taskID string) <-chan TaskEvent

Subscribe は taskID のイベントを受け取るチャネルを返す。 ctx がキャンセルされると、チャネルはクローズされ内部からも除去される。

type TaskHandler

type TaskHandler struct {
	Service  TaskService
	Hooks    HookService       // optional: enables hook replay/list when set
	Notifier TaskNotifyService // optional: enables POST /{id}/notify when set
	Answerer TaskAnswerService // optional: enables POST /{id}/answer when set
}

func (*TaskHandler) Answer

func (h *TaskHandler) Answer(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Create

func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Delete

func (h *TaskHandler) Delete(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Detail

func (h *TaskHandler) Detail(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Duplicate

func (h *TaskHandler) Duplicate(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Field

func (h *TaskHandler) Field(w http.ResponseWriter, r *http.Request)

Field returns a single dotted-path value extracted from the task. The response is text/plain so it can be consumed directly by shell scripts (e.g. `STATUS=$(boid task show $ID --field status)`).

func (*TaskHandler) Get

func (h *TaskHandler) Get(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Import

func (h *TaskHandler) Import(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) List

func (h *TaskHandler) List(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) ListHooks

func (h *TaskHandler) ListHooks(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Notify

func (h *TaskHandler) Notify(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Patch

func (h *TaskHandler) Patch(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) ReplayHook

func (h *TaskHandler) ReplayHook(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Rerun

func (h *TaskHandler) Rerun(w http.ResponseWriter, r *http.Request)

func (*TaskHandler) Routes

func (h *TaskHandler) Routes() chi.Router

type TaskNotifyService

type TaskNotifyService interface {
	NotifyTask(ctx context.Context, taskID, message, ask, questionID, progress, done, fail string) error
}

TaskNotifyService dispatches an agent-driven notification for a task. Wired to *TaskAppService at runtime; left optional on TaskHandler so existing tests do not need to satisfy this interface. ask/questionID are optional Q&A fields: when ask is non-empty the task is transitioned to awaiting after the notification is sent. The session-id resume path was removed, so an answer can only reach an agent that is still alive inside a blocking `boid task ask` RPC. progress is a no-state-change progress note (timeline action only). done / fail are agent self-completion / self-failure: they transition the task to done / aborted respectively and SIGTERM running hook runtimes — the parent supervisor's polling drives the response (verify + optional reopen). ask/progress/done/fail are mutually exclusive.

type TaskService

type TaskService interface {
	CreateTask(req CreateTaskRequest) (*orchestrator.Task, error)
	ListTasks(filter orchestrator.TaskFilter) ([]*orchestrator.Task, error)
	GetTask(id string) (*orchestrator.Task, error)
	GetTaskDetail(id string) (*TaskDetailView, error)
	GetTaskField(id, path string) (string, error)
	UpdateTask(id string, req UpdateTaskRequest) (*orchestrator.Task, error)
	DeleteTask(id string, force bool) error
	ImportTasks(reqs []CreateTaskRequest) (*ImportResult, error)
	DuplicateTask(sourceID string, autoStart bool) (*orchestrator.Task, error)
	RerunTask(id string, req RerunTaskRequest) (*orchestrator.Task, error)
}

type TaskStore

type TaskStore interface {
	CreateTask(task *orchestrator.Task) error
	GetTask(id string) (*orchestrator.Task, error)
	ListTasks(filter orchestrator.TaskFilter) ([]*orchestrator.Task, error)
	UpdateTask(task *orchestrator.Task) error
	DeleteTask(id string) error
	FindTaskByRemote(remoteID string) (*orchestrator.Task, error)
	FindTaskByRef(ref, parentID string) (*orchestrator.Task, error)
	// ListChildren returns direct children (one level only) of the given parent
	// task, ordered by created_at ASC. Returns an empty slice (not nil) when the
	// task has no children. Used by finalizeTerminal to sweep boid/<id8> branches
	// once a supervisor reaches a terminal state.
	ListChildren(parentID string) ([]*orchestrator.Task, error)
}

type TaskWorkflowService

type TaskWorkflowService struct {
	Tasks       TaskStore
	Jobs        JobStore
	Projects    ProjectRepository
	Tx          Transactor
	Meta        MetaStore
	Coordinator DispatchCoordinator
	Lifecycle   JobLifecycle
	Hub         *TaskEventHub
	// Adapter is the harness adapter used to query post-run usage. Phase 3-b
	// dropped the StopAgent role: graceful stop is delivered as a SIGUSR1
	// directly via Lifecycle.SignalJobRuntime, which claude.Adapter.Run()'s
	// signal.Notify handler intercepts. Adapter remains optional; when nil,
	// usage / future per-harness queries become no-ops.
	Adapter adapters.HarnessAdapter
	// contains filtered or unexported fields
}

func (*TaskWorkflowService) ApplyAction

func (*TaskWorkflowService) CompleteJob

func (s *TaskWorkflowService) CompleteJob(_ context.Context, jobID string, req JobDoneRequest) (*Job, error)

func (*TaskWorkflowService) InitDispatch

func (s *TaskWorkflowService) InitDispatch(ctx context.Context)

InitDispatch initialises the lifecycle context used by dispatch-loop goroutines. Must be called before the first action is applied. The returned cancel is stored internally; call Shutdown to invoke it.

func (*TaskWorkflowService) ListHooksForStatus

func (s *TaskWorkflowService) ListHooksForStatus(taskID, status string) ([]orchestrator.Hook, error)

ListHooksForStatus returns hooks that match the given status for the task. If status is empty, the task's current status is used.

func (*TaskWorkflowService) ReplayHook

ReplayHook replays a single hook for the given task. If req.Status is non-empty the task's status is overwritten before dispatch. Running jobs on the same task cause a 409 Conflict.

func (*TaskWorkflowService) Shutdown

func (s *TaskWorkflowService) Shutdown()

Shutdown cancels the dispatch context and blocks until all in-flight dispatch loops have returned. Call this before closing the database.

func (*TaskWorkflowService) StopAgent

func (s *TaskWorkflowService) StopAgent(runtimeID string)

StopAgent gracefully stops the agent backing runtimeID by delivering SIGUSR1 to its process group. claude.Adapter.Run()'s signal.Notify(SIGUSR1) handler forwards a SIGTERM to the claude child and returns Result.StoppedByDaemon=true, so the surrounding sandbox runtime survives long enough to post `boid job done` through the broker normally. No-op when runtimeID is empty or no JobLifecycle has been configured.

type Transactor

type Transactor interface {
	WithinTx(func(TxStore) error) error
}

type TxStore

type TxStore interface {
	TaskStore
	ActionStore
	JobStore
}

type UpdateJobRequest added in v0.0.4

type UpdateJobRequest struct {
	DisplayName *string `json:"display_name"`
}

type UpdateTaskRequest

type UpdateTaskRequest struct {
	Title        string          `json:"title"`
	Description  string          `json:"description"`
	ProjectID    string          `json:"project_id,omitempty"`
	RemoteID     *string         `json:"remote_id,omitempty"`
	Payload      json.RawMessage `json:"payload,omitempty"`
	Instructions json.RawMessage `json:"instructions,omitempty"`
	ParentID     *string         `json:"parent_id,omitempty"`
	AutoStart    *bool           `json:"auto_start,omitempty"`
}

type WSAttachHandler

type WSAttachHandler struct {
	Subscriber dispatcher.RuntimeSubscriber
	Writer     dispatcher.RuntimeInputWriter
	PublicURL  string
	Registry   *auth.ConnectionRegistry

	// Bearer verifies an `Authorization: Bearer <token>` header carried on
	// the WS handshake request (docs/plans/cli-remote-connection.md Phase 3
	// PR0). When present, it is checked before auth.DeviceIDFromContext —
	// see authenticateDevice's doc comment for the precedence rule. PR3
	// moved this handler's mount point in internal/server/wire.go out of
	// the cookie-only WebAuthMiddleware Group so a Bearer-only caller (the
	// CLI's WS-based AttachJob, internal/client/client.go) can actually
	// reach this route end-to-end over TCP; the field itself has existed
	// since PR0.
	Bearer *auth.BearerVerifier
}

WSAttachHandler handles WebSocket connections for interactive PTY attach. Route: GET /api/jobs/{id}/attach/ws

func (*WSAttachHandler) ServeHTTP

func (h *WSAttachHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

type WebAppService

type WebAppService struct {
	Tasks      TaskStore
	Actions    ActionStore
	Jobs       JobStore
	GlobalJobs GlobalJobStore
	Projects   ProjectRepository
	Meta       MetaStore
	Workflow   WorkflowService
	TaskSvc    TaskService
	Hooks      HookService
	Answerer   TaskAnswerService // optional: enables POST /tasks/{id}/answer
}

func (*WebAppService) AnswerTask

func (s *WebAppService) AnswerTask(ctx context.Context, taskID, questionID, answer string) error

func (*WebAppService) ApplyAction

func (s *WebAppService) ApplyAction(taskID string, actionType string) error

func (*WebAppService) CreateTask

func (s *WebAppService) CreateTask(req CreateTaskRequest) (*orchestrator.Task, error)

func (*WebAppService) DeleteTask

func (s *WebAppService) DeleteTask(id string, force bool) error

DeleteTask delegates to the shared TaskService so the web UI uses the same delete semantics as the JSON API and TUI.

func (*WebAppService) DuplicateTask

func (s *WebAppService) DuplicateTask(id string) (string, error)

DuplicateTask delegates to the shared TaskService so the Web UI uses the same duplication semantics as the JSON API: a fresh task is created via CreateTask + resolveBehavior so that Instructions and Payload come from the behavior's DefaultInstruction / DefaultPayload, not from the source task's runtime state. Without this delegation the duplicate inherited the source's runtime payload (awaiting trait and harness session state) and missing Instructions caused the hook evaluator to skip the agent hook, so no hook fired on Start.

The Web UI button does not auto-start the duplicate; the user clicks Start separately.

func (*WebAppService) GetJob

func (s *WebAppService) GetJob(id string) (*JobWithContext, error)

func (*WebAppService) GetProjectByID

func (s *WebAppService) GetProjectByID(id string) (*orchestrator.Project, error)

func (*WebAppService) GetTaskDetail

func (s *WebAppService) GetTaskDetail(id string) (*TaskDetailView, error)

func (*WebAppService) ListBehaviors

func (s *WebAppService) ListBehaviors() ([]string, error)

func (*WebAppService) ListHooksForStatus

func (s *WebAppService) ListHooksForStatus(taskID, status string) ([]orchestrator.Hook, error)

func (*WebAppService) ListJobs

func (s *WebAppService) ListJobs(status string) ([]JobWithContext, error)

func (*WebAppService) ListProjects

func (s *WebAppService) ListProjects() ([]*orchestrator.Project, error)

func (*WebAppService) ListSessions added in v0.0.2

func (s *WebAppService) ListSessions() ([]JobWithContext, error)

func (*WebAppService) ListTasks

func (s *WebAppService) ListTasks(filter orchestrator.TaskFilter) ([]*orchestrator.Task, error)

func (*WebAppService) ListWorkspaces

func (s *WebAppService) ListWorkspaces() ([]*orchestrator.WorkspaceSummary, error)

func (*WebAppService) ReopenTask

func (s *WebAppService) ReopenTask(id string, req ReopenTaskRequest) error

func (*WebAppService) ReplayHook

func (s *WebAppService) ReplayHook(ctx context.Context, taskID string, req ReplayHookRequest) (*ReplayHookResult, error)

func (*WebAppService) RerunTask

func (s *WebAppService) RerunTask(id string, req RerunTaskRequest) error

func (*WebAppService) UpdateTask

func (s *WebAppService) UpdateTask(id string, req UpdateTaskRequest) error

type WebHandler

type WebHandler struct {
	Service           WebService
	Hub               *TaskEventHub
	SessionDispatcher SessionDispatcher
	Registry          *auth.ConnectionRegistry

	// AttachmentsRoot is the data-home directory under which per-task
	// attachments (`tasks/<id>/attachments`) are persisted. When empty (e.g.
	// :memory: DB during tests) the multipart code path falls back to
	// rejecting attachments while still accepting plain form-urlencoded
	// submissions.
	AttachmentsRoot string
}

func (*WebHandler) GetTaskEdit

func (h *WebHandler) GetTaskEdit(w http.ResponseWriter, r *http.Request)

func (*WebHandler) HookReplayList

func (h *WebHandler) HookReplayList(w http.ResponseWriter, r *http.Request)

func (*WebHandler) JobDetail

func (h *WebHandler) JobDetail(w http.ResponseWriter, r *http.Request)

func (*WebHandler) JobTerminal

func (h *WebHandler) JobTerminal(w http.ResponseWriter, r *http.Request)

JobTerminal redirects legacy deep-links (/jobs/{id}/terminal) to the job detail page (/jobs/{id}), which now renders the terminal inline.

func (*WebHandler) PostAction

func (h *WebHandler) PostAction(w http.ResponseWriter, r *http.Request)

func (*WebHandler) PostAnswer

func (h *WebHandler) PostAnswer(w http.ResponseWriter, r *http.Request)

func (*WebHandler) PostDelete

func (h *WebHandler) PostDelete(w http.ResponseWriter, r *http.Request)

PostDelete deletes the task and redirects to the task list. Errors are surfaced via ?error= on the same task page so the user sees the reason (e.g. dependents exist).

func (*WebHandler) PostDuplicate

func (h *WebHandler) PostDuplicate(w http.ResponseWriter, r *http.Request)

func (*WebHandler) PostEdit

func (h *WebHandler) PostEdit(w http.ResponseWriter, r *http.Request)

func (*WebHandler) PostHookReplay

func (h *WebHandler) PostHookReplay(w http.ResponseWriter, r *http.Request)

func (*WebHandler) PostReopen

func (h *WebHandler) PostReopen(w http.ResponseWriter, r *http.Request)

func (*WebHandler) PostRerun

func (h *WebHandler) PostRerun(w http.ResponseWriter, r *http.Request)

func (*WebHandler) PostStartSession added in v0.0.6

func (h *WebHandler) PostStartSession(w http.ResponseWriter, r *http.Request)

PostStartSession launches a HarnessAdapter-backed session for the project from the Web UI's [New Session] dialog. Phase 3-d (PR1) introduced this alongside PostProjectExecuteCommand so users can start a claude / codex / opencode session without going through the legacy commands path.

func (*WebHandler) PostTaskCreate

func (h *WebHandler) PostTaskCreate(w http.ResponseWriter, r *http.Request)

func (*WebHandler) QuestionPage

func (h *WebHandler) QuestionPage(w http.ResponseWriter, r *http.Request)

QuestionPage renders the dedicated Q&A turn page at `/tasks/{id}/questions/{question_id}`. The notification deep-link from `boid task notify --ask` lands here. The page shows the question and either an answer form (when this is the active awaiting turn) or the recorded answer (when an answer action exists for the same question_id).

func (*WebHandler) ReopenForm

func (h *WebHandler) ReopenForm(w http.ResponseWriter, r *http.Request)

func (*WebHandler) Routes

func (h *WebHandler) Routes() chi.Router

func (*WebHandler) SessionList added in v0.0.2

func (h *WebHandler) SessionList(w http.ResponseWriter, r *http.Request)

func (*WebHandler) SessionNew added in v0.0.2

func (h *WebHandler) SessionNew(w http.ResponseWriter, r *http.Request)

func (*WebHandler) TaskDetail

func (h *WebHandler) TaskDetail(w http.ResponseWriter, r *http.Request)

func (*WebHandler) TaskDetailFragment

func (h *WebHandler) TaskDetailFragment(w http.ResponseWriter, r *http.Request)

TaskDetailFragment returns a partial HTML fragment for the task detail page. The `kind` query parameter selects which section to render:

  • "timeline": action history section
  • "status": status card + available actions
  • "jobs": jobs section

func (*WebHandler) TaskEvents

func (h *WebHandler) TaskEvents(w http.ResponseWriter, r *http.Request)

TaskEvents streams Server-Sent Events for a specific task. Subscribes to the TaskEventHub and forwards events until the client disconnects or the device is revoked.

func (*WebHandler) TaskList

func (h *WebHandler) TaskList(w http.ResponseWriter, r *http.Request)

func (*WebHandler) TaskNew

func (h *WebHandler) TaskNew(w http.ResponseWriter, r *http.Request)

type WebManagementHandler

type WebManagementHandler struct {
	Pairing   Pairer
	Store     *auth.Store
	PublicURL string
	Registry  *auth.ConnectionRegistry
}

func (*WebManagementHandler) DeleteAllDevices

func (h *WebManagementHandler) DeleteAllDevices(w http.ResponseWriter, r *http.Request)

func (*WebManagementHandler) DeleteDevice

func (h *WebManagementHandler) DeleteDevice(w http.ResponseWriter, r *http.Request)

func (*WebManagementHandler) GetDevices

func (h *WebManagementHandler) GetDevices(w http.ResponseWriter, r *http.Request)

func (*WebManagementHandler) PostPair

func (*WebManagementHandler) Routes

func (h *WebManagementHandler) Routes() chi.Router

type WebService

type WebService interface {
	ListTasks(filter orchestrator.TaskFilter) ([]*orchestrator.Task, error)
	GetTaskDetail(id string) (*TaskDetailView, error)
	ListProjects() ([]*orchestrator.Project, error)
	ListBehaviors() ([]string, error)
	ListWorkspaces() ([]*orchestrator.WorkspaceSummary, error)
	ApplyAction(taskID string, actionType string) error
	DuplicateTask(id string) (string, error)
	DeleteTask(id string, force bool) error
	ListJobs(status string) ([]JobWithContext, error)
	ListSessions() ([]JobWithContext, error)
	GetJob(id string) (*JobWithContext, error)
	CreateTask(req CreateTaskRequest) (*orchestrator.Task, error)
	UpdateTask(id string, req UpdateTaskRequest) error
	RerunTask(id string, req RerunTaskRequest) error
	ReopenTask(id string, req ReopenTaskRequest) error
	AnswerTask(ctx context.Context, taskID, questionID, answer string) error
	ListHooksForStatus(taskID, status string) ([]orchestrator.Hook, error)
	ReplayHook(ctx context.Context, taskID string, req ReplayHookRequest) (*ReplayHookResult, error)
	GetProjectByID(id string) (*orchestrator.Project, error)
}

type WorkflowService

type WorkflowService interface {
	ApplyAction(ctx context.Context, taskID string, req ApplyActionRequest) (*ActionApplication, error)
	CompleteJob(ctx context.Context, jobID string, req JobDoneRequest) (*Job, error)
	// StopAgent asks the agent backing runtimeID to terminate gracefully,
	// without tearing down the surrounding runner-inner-child. The broker's
	// `boid job done` call still fires normally, preserving any payload
	// patch the agent wrote up to that point. NotifyTask calls this after
	// `ApplyAction("ask")` so the awaiting transition does not race with
	// payload_patch capture. No-op when runtimeID is empty.
	StopAgent(runtimeID string)
}

type WorkspaceDetail added in v0.0.13

type WorkspaceDetail struct {
	Slug     string                      `json:"slug"`
	Meta     *orchestrator.WorkspaceMeta `json:"meta"`
	Revision string                      `json:"revision,omitempty"`
	// ProjectCount mirrors len(AssignedProjects); kept as its own field so
	// callers that only need the count (e.g. a future list-style summary
	// view) don't need to len() the slice themselves.
	ProjectCount     int      `json:"project_count"`
	AssignedProjects []string `json:"assigned_projects"`
	// Home reports the workspace home directory's on-disk size (docs/plans/
	// home-workspace-volume.md Phase 4 PR5). Populated only by GET
	// /api/workspaces/{slug} (WorkspaceHandler.Show) — Create/Update/Import
	// leave it nil, since computing it means walking a directory tree and
	// none of those callers need it. nil (omitted from the JSON body) when
	// the handler was not wired with a RuntimesDir to resolve it from.
	Home *WorkspaceHomeSize `json:"home,omitempty"`
}

WorkspaceDetail is the response shape for the workspace create/show/update endpoints: the parsed meta plus enough bookkeeping (revision, assigned project ids) for a caller to display or re-PUT it with the correct If-Match header. docs/plans/workspace-db-consolidation.md Step C/D/E.

type WorkspaceHandler

type WorkspaceHandler struct {
	Service ProjectService
	// RuntimesDir, when non-empty, is server/wire.go's runtimesDirFor(cfg) —
	// used to resolve each workspace's home directory (docs/plans/
	// home-workspace-volume.md Phase 4 PR5) for Show's size reporting and
	// Remove's home-directory deletion. Left empty, both features degrade
	// gracefully: Show omits WorkspaceDetail.Home, Remove reports
	// home_deleted=false with no attempt made.
	RuntimesDir string
}

func (*WorkspaceHandler) Create added in v0.0.13

func (h *WorkspaceHandler) Create(w http.ResponseWriter, r *http.Request)

Create handles POST /api/workspaces (docs/plans/workspace-db-consolidation.md PR4 Step C). The body is a yaml document with the target slug inlined (`slug: foo`) alongside the workspace meta fields — there is no URL parameter for the new slug, since the daemon does not yet know it.

func (*WorkspaceHandler) Export added in v0.0.13

func (h *WorkspaceHandler) Export(w http.ResponseWriter, r *http.Request)

Export handles GET /api/workspaces/{slug}/export (docs/plans/ workspace-db-consolidation.md PR5 Step A): the response body is the raw yaml the service returns verbatim (the marshaled WorkspaceMeta with a top-level "slug:" key inlined — the exact same shape POST /api/workspaces/import accepts, so an export → import round-trip needs no translation step — see ProjectAppService.ExportWorkspace's doc comment for the rationale). An ETag header mirrors the revision so a caller can round-trip it into a subsequent PUT's If-Match if it chooses that route instead of POST import.

func (*WorkspaceHandler) Import added in v0.0.13

func (h *WorkspaceHandler) Import(w http.ResponseWriter, r *http.Request)

Import handles POST /api/workspaces/import?mode=<create-only|replace> (docs/plans/workspace-db-consolidation.md PR5 Step B): the body shape mirrors Create's (top-level "slug:" key alongside the meta fields, decoded by the same DecodeWorkspaceCreateStrict). mode defaults to "create-only" (the safe choice — never overwrites an existing workspace) when the query param is omitted; an unrecognized mode value is rejected by ImportWorkspace itself with 400.

func (*WorkspaceHandler) List

func (*WorkspaceHandler) Remove added in v0.0.13

func (h *WorkspaceHandler) Remove(w http.ResponseWriter, r *http.Request)

Remove handles DELETE /api/workspaces/{slug} (Step F). The reserved default slug and re-assignment of any still-assigned project are enforced at the service/repository layer (ProjectAppService.RemoveWorkspace → orchestrator.WorkspaceRepository.Remove's transaction).

docs/plans/home-workspace-volume.md Phase 4 PR5 adds home directory deletion on top of the pre-existing row removal: the workspace row is always removed first (via h.Service.RemoveWorkspace), and only then does this attempt to delete slug's home directory on disk — trusted-side deletion, since a sandboxed job or a remote CLI client has no way to remove a path on the daemon's own filesystem itself. A home-deletion failure (e.g. permission denied) is reported in the response but does not turn this into an error response: the row is already gone, and the plan doc explicitly allows this "part-completed" outcome (see WorkspaceRemoveResponse's doc comment) rather than trying to make the two deletions atomic.

func (*WorkspaceHandler) Routes

func (h *WorkspaceHandler) Routes() chi.Router

func (*WorkspaceHandler) Show added in v0.0.13

Show handles GET /api/workspaces/{slug} (Step D): meta + summary (revision, assigned project ids), with an ETag response header mirroring the revision so a client can round-trip it straight into a subsequent PUT's If-Match.

func (*WorkspaceHandler) Update added in v0.0.13

func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request)

Update handles PUT /api/workspaces/{slug} (Step E): whole-document replace, gated by If-Match unless ?force=true is passed (decision 17 — PUT + If-Match, no PATCH). See ProjectAppService.UpdateWorkspace for the exact status code contract (428 missing If-Match, 412 stale If-Match).

type WorkspaceHomeSize added in v0.0.13

type WorkspaceHomeSize struct {
	Slug string `json:"slug"`
	// Path is the absolute on-disk path to the home directory, whether or
	// not it currently exists.
	Path string `json:"path"`
	// Exists reports whether Path exists on disk. false means the workspace
	// has never been dispatched into (docs/plans/home-workspace-volume.md's
	// init-on-first-dispatch contract) — not an error.
	Exists bool `json:"exists"`
	// Bytes is the recursive apparent size of Path (humanize.ApparentSize —
	// not a `du`-equivalent block-based size, see that function's doc
	// comment), meaningful only when Exists is true and SizeError is empty.
	Bytes int64 `json:"bytes"`
	// Orphan is true when Path was found on disk but Slug has no
	// corresponding workspace row — a workspace.yaml/DB row that was
	// removed (or never assign/create'd) while its home directory survived.
	// Only ever set by the GC listing (ListWorkspaceHomeSizes); the single-
	// slug lookup used by GET /api/workspaces/{slug} always has a live
	// workspace row (it is 404 otherwise), so it never carries Orphan=true.
	Orphan bool `json:"orphan"`
	// SizeError is non-empty when humanize.ApparentSize (or stat-ing Path in
	// the first place) failed for a reason other than "does not exist" —
	// typically a permission error, or a concurrent dispatch mutating the
	// tree mid-walk. Bytes is 0 and must not be trusted when this is set;
	// callers render "?" instead (docs/plans/home-workspace-volume.md PR5:
	// "エラー時はエラーにせず「?」表示"). A non-empty SizeError does *not*
	// imply the directory itself is gone or undeletable — deleteWorkspaceHome
	// treats sizing as best-effort and still attempts deletion regardless
	// (codex PR #791 review, Should-fix #2).
	SizeError string `json:"size_error,omitempty"`
}

WorkspaceHomeSize describes one workspace home directory's on-disk footprint (docs/plans/home-workspace-volume.md Phase 4 PR5): used both by GET /api/workspaces/{slug} (a single entry, WorkspaceDetail.Home) and by POST /api/gc's workspace_homes listing (one entry per directory found under homes/).

func ListWorkspaceHomeSizes added in v0.0.13

func ListWorkspaceHomeSizes(runtimesDir string, lister WorkspaceSlugLister) (homes []WorkspaceHomeSize, listErr string, err error)

ListWorkspaceHomeSizes enumerates every directory under runtimesDir's sibling homes/ directory (docs/plans/home-workspace-volume.md's layout: homes/<slug>/, alongside sibling homes/<slug>.init.json and homes/<slug>.lock files that os.ReadDir's IsDir() filter excludes), and reports each one's size. Unlike computeWorkspaceHomeSize's single-slug lookup (driven by an already-known, already-validated slug), this walks the directory itself — so it also surfaces home directories with no corresponding workspace row at all ("orphans": docs/plans/ home-workspace-volume.md PR5: "workspace.yaml が消えて home だけ残った 孤児はレポートのみ"), which lister (when non-nil) is consulted to detect.

Orphan detection depends on a successful WorkspaceSlugLister.List call. A lister failure means orphan status is simply unknowable for every entry — rather than marking every entry Orphan=true (this function's pre-#791- review behavior, which silently misreported every real workspace's home as an "orphan" on a merely transient DB hiccup), a lister failure now omits the listing outright: homes comes back an empty (non-nil) slice, and listErr carries the lister's error message so a caller can render "size listing unavailable: <reason>" instead of trusting bogus per-entry orphan flags. This preserves the invariant "every WorkspaceHomeSize actually returned has a trustworthy Orphan flag" (codex PR #791 review, Should-fix #3, selection A — the plan's rejected alternative, selection B, would instead add a 3-state `orphan_known bool` per entry).

err (the third return) is reserved for a genuine directory-walk failure — an unresolvable runtimesDir, or os.ReadDir failing for a reason other than "does not exist yet". Unlike listErr, a non-nil err means the whole call failed, not just orphan detection; GC's own record-deletion work does not depend on workspace-home reporting succeeding, so callers should still treat a non-nil err as non-fatal to the rest of their own response.

Returns an empty (non-nil) slice, not an error, when homes/ does not exist yet (a fresh installation that has never dispatched a job).

type WorkspaceRemoveResponse added in v0.0.13

type WorkspaceRemoveResponse struct {
	Status string `json:"status"`
	// HomePath/HomeBytes describe the home directory as it was found right
	// before the deletion attempt (empty/0 when RuntimesDir was not wired
	// into the handler, or slug is the reserved default workspace). HomeBytes
	// is only trustworthy when HomeSizeError is empty.
	HomePath  string `json:"home_path,omitempty"`
	HomeBytes int64  `json:"home_bytes,omitempty"`
	// HomeSizeError is non-empty when the daemon could not determine
	// HomePath's on-disk size before attempting deletion (mirrors
	// WorkspaceHomeSize.SizeError) — independent of whether the deletion
	// itself subsequently succeeded. Split out from HomeDeleteError (codex
	// PR #791 review, Should-fix #2): the two used to be conflated into a
	// single field, so a caller could not tell "we don't know the size" (a
	// diagnostic-only hiccup, e.g. a concurrent dispatch mutating the tree
	// mid-walk) apart from "the directory is still there and undeletable" (a
	// real part-completed-remove problem worth investigating).
	HomeSizeError string `json:"home_size_error,omitempty"`
	// HomeDeleted is true only when a home directory existed and os.RemoveAll
	// on it succeeded. false covers every other case: no RuntimesDir wired,
	// the default workspace's protected home, no home directory to begin
	// with, or a deletion failure (see HomeDeleteError for that last one).
	// Sizing is best-effort (see HomeSizeError) and never gates whether
	// deletion is attempted — HomeDeleted can be true even when
	// HomeSizeError is also non-empty.
	HomeDeleted bool `json:"home_deleted"`
	// HomeDeleteError is non-empty only when os.RemoveAll on the home
	// directory itself failed. Never populated merely because sizing failed
	// (see HomeSizeError for that).
	HomeDeleteError string `json:"home_delete_error,omitempty"`
}

WorkspaceRemoveResponse is the response body for DELETE /api/workspaces/{slug} (docs/plans/home-workspace-volume.md Phase 4 PR5). The workspace row is always removed before any home-directory deletion is attempted (WorkspaceHandler.Remove calls Service.RemoveWorkspace first), so a non-empty HomeDeleteError (or HomeSizeError) reports a *partially* completed remove — deliberately allowed per the plan doc ("削除失敗... workspace 設定 (DB) の削除は先に完了させる (part-completed 状態を許容...)") rather than treated as a request failure: the response status stays 200.

type WorkspaceSlugLister added in v0.0.13

type WorkspaceSlugLister interface {
	ListWorkspaces() ([]*orchestrator.WorkspaceSummary, error)
}

WorkspaceSlugLister exposes the set of currently known workspace slugs, used to flag orphaned home directories (a directory under homes/ with no corresponding workspace row). *ProjectAppService already satisfies this via its existing ListWorkspaces method — no new implementation is needed, only threading it through as this narrower interface at the handler construction site (server/wire.go) so GCHandler does not need the whole ProjectService surface.

type WorkspaceStore added in v0.0.13

type WorkspaceStore interface {
	Load(slug string) (*orchestrator.WorkspaceMeta, error)
	Save(slug string, meta *orchestrator.WorkspaceMeta) error
	// Create is insert-only: an error wrapping os.ErrExist when slug already
	// has a row (see orchestrator.WorkspaceRepository.Create).
	Create(slug string, meta *orchestrator.WorkspaceMeta) error
	Remove(slug string) error
	// LoadWithRevision returns meta and its revision from a single atomic
	// snapshot (docs/plans/workspace-db-consolidation.md MAJOR 1, codex
	// review), used by GET /api/workspaces/{slug} so meta and revision can
	// never straddle a concurrent write. See
	// orchestrator.WorkspaceRepository.LoadWithRevision's doc comment.
	LoadWithRevision(slug string) (*orchestrator.WorkspaceMeta, string, error)
	// UpdateIfRevisionMatches performs a compare-and-swap update: meta is
	// written only if slug's current revision equals expectedRevision,
	// atomically with the check. matched=false covers both "no such slug"
	// and "revision mismatch" — see
	// orchestrator.WorkspaceRepository.UpdateIfRevisionMatches's doc comment.
	UpdateIfRevisionMatches(slug string, expectedRevision string, meta *orchestrator.WorkspaceMeta) (newRevision string, matched bool, err error)
}

WorkspaceStore provides direct CRUD over a single workspace's WorkspaceMeta, independent of the project-assignment bookkeeping that lives on ProjectRepository below (docs/plans/workspace-db-consolidation.md PR4). Implemented by *orchestrator.WorkspaceStore (via ProjectStore.WorkspaceStore()), wired in internal/server/wire.go.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL