orchestrator

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: 31 Imported by: 0

Documentation

Index

Constants

View Source
const (
	OpBoidJobDone    = "job_done"
	OpBoidJobList    = "job_list"
	OpBoidJobShow    = "job_show"
	OpBoidJobLog     = "job_log"
	OpBoidActionSend = "action_send"
	OpBoidAgentStop  = "agent_stop"
	OpBoidTaskCreate = "task_create"
	OpBoidTaskGet    = "task_get"
	OpBoidTaskUpdate = "task_update"
	OpBoidTaskImport = "task_import"
	OpBoidTaskReopen = "task.reopen"
	OpBoidTaskList   = "task_list"
	OpBoidTaskNotify = "task_notify"
	OpBoidTaskAnswer = "task_answer"
	OpBoidTaskAsk    = "task_ask"
	OpBoidTaskDelete = "task_delete"

	// Phase 5b PR1 task-context RPCs (docs/plans/phase5-shim-and-task-context.md).
	OpBoidTaskCurrent      = "task_current"
	OpBoidTaskInstructions = "task_instructions"
	OpBoidTaskEnv          = "task_env"
	OpBoidTaskPayload      = "task_payload"

	// Phase 5b PR2 attachments RPCs (docs/plans/phase5-shim-and-task-context.md).
	OpBoidTaskAttachmentsList = "task_attachments_list"
	OpBoidTaskAttachmentsGet  = "task_attachments_get"

	// Phase 5b PR7 job_done payload_patch direct-pass RPC
	// (docs/plans/phase5-shim-and-task-context.md).
	OpBoidTaskUpdatePayloadPatch = "task_update_payload_patch"

	OpFetchGet = "get"
)

Mirror of sandbox layer's op constants. orchestrator cannot import sandbox (that would reverse the layer direction), so these are kept as string literals that must stay in lock-step with internal/sandbox/protocol.go. Drift is detected by internal/dispatcher/policy_translate_test.go — the only layer allowed to see both sides.

View Source
const DefaultBehavior = "supervisor"

DefaultBehavior is the hardcoded fallback behavior name used when a CreateTaskRequest omits both behavior and behavior_spec, the project meta is nil, and no default_task_behavior is configured. When meta is non-nil, the default resolution consults meta.DefaultTaskBehavior first, then falls back to "supervisor" with a deprecation warning if that behavior exists.

View Source
const DefaultWorkspaceSlug = "default"

DefaultWorkspaceSlug is the reserved slug for the implicit "default" workspace that every project belongs to when no explicit assignment is chosen. It is auto-created at daemon startup, used as the fallback target for `project add` / `project migrate` when --workspace is omitted, and guarded against deletion by WorkspaceStore.Remove.

View Source
const ParentIDSentinelRoot = "-"

ParentIDSentinelRoot is a sentinel value for CreateTaskRequest.ParentID that explicitly requests root-task creation. When this value is detected at an entry point (sandbox, CLI, HTTP API), auto-populate is skipped and the stored parent_id is left empty. Use this when a root task must be created from inside a child context where auto-populate would otherwise attach the current task as parent.

Variables

View Source
var BehaviorAliases = map[string]string{
	"plan": "supervisor",
	"dev":  "executor",
}

BehaviorAliases maps legacy behavior names to their canonical counterparts. This is the alias table used during the "task_behavior simplification" rename: project.yaml files written before the rename keep using "plan" / "dev"; on load they are normalized to "supervisor" / "executor" and a deprecation warning is emitted. The map is intentionally not exported as mutable state — callers should go through CanonicalBehaviorName.

View Source
var ErrDetachedHead = fmt.Errorf("project is in detached HEAD state")

ErrDetachedHead is returned by ClassifyBaseBranch when the project working directory is in detached-HEAD state. Wrapped so callers can use errors.Is.

View Source
var ErrTaskNotFound = errors.New("task not found")

ErrTaskNotFound is returned by scanTask (and propagated by GetTask, FindTaskByRemote, FindTaskByRef) when no matching task row exists. Callers should check for it with errors.Is rather than matching on error strings.

Functions

func AssignDefaultWorkspaceToUnlinked added in v0.0.8

func AssignDefaultWorkspaceToUnlinked(dbtx db.DBTX, workspaceID string) (int, error)

AssignDefaultWorkspaceToUnlinked inserts a project_workspaces row pointing at workspaceID for every project that does not yet have one. Used at daemon startup to migrate legacy unlinked projects to the default workspace. The INSERT ... SELECT pattern keeps the operation idempotent and atomic in a single statement.

Returns (number of rows inserted, error). Pass the DefaultWorkspaceSlug to land projects in the implicit default workspace.

func AssignWorkspaceIfExists added in v0.0.13

func AssignWorkspaceIfExists(conn *sql.DB, projectID, workspaceID string) error

AssignWorkspaceIfExists atomically checks that workspaceID has a corresponding workspaces row and, if so, assigns projectID to it — all in a single transaction (docs/plans/workspace-db-consolidation.md MAJOR 3, codex review). This replaces the previous WorkspaceExists+ SetProjectWorkspace two-step, which ran as two separate statements: a DELETE landing between them could remove the workspaces row after the existence check passed but before the assign committed, leaving a dangling project_workspaces reference the same MAJOR-5 fix this supersedes was meant to prevent. dbtx must be a *sql.DB (a fresh transaction is opened internally) — passing an existing *sql.Tx would attempt a nested BEGIN, which SQLite does not support the way this function needs.

workspaceID == "" clears the assignment (bypassing the existence check — there is no slug to check), and DefaultWorkspaceSlug is exempt from the check (WorkspaceRepository.EnsureDefault guarantees it always exists), mirroring SetProjectWorkspace's own exemptions.

func CanonicalBehaviorName

func CanonicalBehaviorName(name string) (canonical string, isAlias bool)

CanonicalBehaviorName returns the canonical behavior name for the given (possibly aliased) name. If the input is a deprecated alias, the returned canonical name and isAlias=true are returned. Otherwise the input is returned unchanged with isAlias=false.

func ClearPendingAnswer

func ClearPendingAnswer(payload json.RawMessage) json.RawMessage

ClearPendingAnswer removes the pending_answer field from the awaiting trait while preserving session_id, question, and question_id. This is called after a hook is spawned so the answer is not consumed again on the next resume. Returns payload unchanged when the awaiting trait is absent or has no answer.

func CloneHostCommandsMap added in v0.0.13

func CloneHostCommandsMap(m map[string]HostCommandSpec) map[string]HostCommandSpec

CloneHostCommandsMap returns a deep copy of m: a new top-level map, plus independent copies of every HostCommandSpec's slice/map fields. Callers such as Server.HostCommands() use this to hand out a snapshot rather than the live internal map, so a caller mutating the returned value (or a slice/map nested inside one of its entries) can never perturb daemon state. A nil input returns nil.

func CreateAction

func CreateAction(dbtx db.DBTX, a *Action) error

func CreateProject

func CreateProject(dbtx db.DBTX, project *Project) error

CreateProject inserts a new project record.

func CreateTask

func CreateTask(dbtx db.DBTX, t *Task) error

func DefaultBuiltinPolicies

func DefaultBuiltinPolicies(role Role, names []string, pctx PolicyContext) map[string]BuiltinPolicy

DefaultBuiltinPolicies creates per-command BuiltinPolicy values for the given role and command names. "boid" is always available as a builtin; pass it explicitly via names.

func DefaultHostCommandsPath added in v0.0.13

func DefaultHostCommandsPath() (string, error)

DefaultHostCommandsPath returns the default path for the aggregated host_commands.yaml config: $XDG_CONFIG_HOME/boid/host_commands.yaml, or ~/.config/boid/host_commands.yaml when XDG_CONFIG_HOME is unset (matching the behaviour of os.UserConfigDir on Linux). Mirrors DefaultWorkspaceDir's resolution strategy (workspace_store.go).

func DefaultWorkspaceDir added in v0.0.8

func DefaultWorkspaceDir() (string, error)

DefaultWorkspaceDir returns the default directory for workspace YAML files: $XDG_CONFIG_HOME/boid/workspaces, or ~/.config/boid/workspaces when XDG_CONFIG_HOME is unset (matching the behaviour of os.UserConfigDir on Linux).

func DeleteProject

func DeleteProject(dbtx db.DBTX, id string) error

DeleteProject removes a project by ID. All tasks (and their dependent records) belonging to the project are deleted first. Standalone jobs (task_id NULL session / hook) are also swept by project_id so the jobs.project_id FK constraint does not refuse the project delete.

func DeleteTask

func DeleteTask(dbtx db.DBTX, id string) error

func ExpandBaseBranch

func ExpandBaseBranch(value, workDir string) (string, error)

ExpandBaseBranch expands ${current_branch} (or $current_branch) in value by reading the HEAD branch of the git repo at workDir. Unknown variables are left as-is for backward compatibility. Returns an error if the git command fails or the repo is in detached HEAD state.

func ExpandHostCommandsForDispatch added in v0.0.13

func ExpandHostCommandsForDispatch(raw map[string]HostCommandSpec) map[string]HostCommandSpec

ExpandHostCommandsForDispatch returns a clone of raw with each entry's Path and Env values expanded via interpolateHostCommands against the daemon process's own environment (MAJOR 2 codex review finding). The aggregated host_commands.yaml config on disk — and the map this function receives when called with the value LoadHostCommandsConfig just parsed — stores raw/unexpanded definitions by design (see readKitHostCommandsRaw's doc comment: expanding at aggregation time would bake resolved values, possibly secret-shaped, into that file, and would let two kits using differently-named placeholders that happen to resolve to the same value silently evade collision detection). But a raw `path: ${HOME}/bin/gh` is useless to exec.LookPath at dispatch time — something has to expand it before ProjectStore ever hands a HostCommandSpec to the sandbox.

Call this once at daemon startup on the freshly loaded raw config and wire the *expanded* result into ProjectStore.SetHostCommands (the dispatch-time read path), while Server.HostCommands() keeps handing out the raw CloneHostCommandsMap snapshot unchanged (the PR2 contract: the on-disk file and the API-visible snapshot both stay raw/inspectable).

raw is never mutated: the returned map (and every HostCommandSpec.Env value map inside it) is an independent deep copy (CloneHostCommandsMap) before interpolateHostCommands runs in place on that copy.

func ExpandTaskBaseBranch

func ExpandTaskBaseBranch(template, remoteID string) (string, error)

ExpandTaskBaseBranch expands the ${TASK_REMOTE_ID} template variable in template against the supplied remoteID. Other template variables are rejected with one specific carve-out: ${current_branch} (the variable owned by ExpandBaseBranch) is preserved as-is so callers can compose the two expanders without ordering surprises.

Contract:

  • Static values (no ${...} or $... markers, or only ${current_branch}) pass through unchanged. An empty remoteID is fine in that case.
  • If the template references ${TASK_REMOTE_ID} but remoteID is empty, expansion fails: task creation must not silently produce a broken branch name. Callers are expected to surface this as a 400.
  • Any other ${VAR} or $VAR reference is rejected. This intentionally restricts the surface; the longer-term plan is to keep template variables to an explicit allow-list rather than letting arbitrary env vars leak into task state.

func FilterPayloadByTraits

func FilterPayloadByTraits(payload json.RawMessage, consumes []TraitType) json.RawMessage

FilterPayloadByTraits returns a payload containing only the top-level keys matching the listed traits. If consumes is empty, an empty payload is returned.

func FormatMigrationIssue added in v0.0.8

func FormatMigrationIssue(p ProjectMigrationIssue) string

FormatMigrationIssue renders a single ProjectMigrationIssue using the canonical multi-line format. When ProjectID is non-empty the output is prefixed with `project "<ID>": ` to mirror the wrapping previously done by project_store.LoadAll via fmt.Errorf("project %q: %w", id, err).

Exported so that callers building aggregate messages (server/wire.go, boid start parent) can reuse the same canonical format for each issue.

func IsBehaviorAliasKey

func IsBehaviorAliasKey(key string) bool

IsBehaviorAliasKey reports whether the given key is a deprecated alias key. Display code that needs to suppress mirror entries for the migration period (so user-facing output does not show the same behavior twice) can skip keys where IsBehaviorAliasKey returns true and the canonical counterpart is also present in the same map.

func IsInstructionsEditable

func IsInstructionsEditable(status TaskStatus) bool

IsInstructionsEditable reports whether a task's instructions can be edited in the given status. Editing is only allowed while the task is pending to avoid racing with in-flight handlers and to prevent post-execution mutations.

func IsReadonly

func IsReadonly(task *Task) bool

IsReadonly returns true if the task's working directory should be mounted read-only. Driven solely by the task.readonly flag (e.g. plan tasks).

func LoadHostCommandsConfig added in v0.0.13

func LoadHostCommandsConfig(path string) (map[string]HostCommandSpec, error)

LoadHostCommandsConfig reads and parses the aggregated host_commands.yaml config at path. A missing or empty file is not an error — it returns an empty map — since the file may not have been written yet (fresh install, before the first daemon-startup preflight runs). Parsing is strict at every level (top-level keys and each command's fields, including nested reject rules — see hostCommandSpecStrict) so a typo in the hand-edited file (docs/plans/workspace-db-consolidation.md's documented edit path) surfaces as a load error rather than being silently ignored.

func LoadHostCommandsFromKits added in v0.0.13

func LoadHostCommandsFromKits(kitsDir string) (map[string]HostCommandSpec, error)

LoadHostCommandsFromKits scans kitsDir for installed kits — each a subdirectory containing a kit.yaml — and aggregates their host_commands sections into a single map keyed by command name. Definitions are read raw (readKitHostCommandsRaw): no env-var interpolation and no other kit.yaml validation runs on this path (see readKitHostCommandsRaw's doc comment).

A missing kitsDir (no kits installed yet, or KitsDir unconfigured) is not an error: it returns an empty map, matching the "空扱い" decision in docs/plans/workspace-db-consolidation.md (host_commands 実定義の集約先).

Two kits may declare the same command name only when the definitions are identical after normalizeHostCommandSpec (nil/empty slice-and-map fields unified) and reflect.DeepEqual — that case is silently deduped into one entry. If two kits declare the same name with different definitions, this returns an error naming both kit directories and the conflicting command; callers are expected to treat this as fatal (daemon startup abort per decision 9 in the plan doc — "host_command 名前衝突は migration fail").

func MarshalProjectLocalMeta

func MarshalProjectLocalMeta(meta *ProjectLocalMeta) ([]byte, error)

func MaterializeWorkspaceKitsForPersist added in v0.0.13

func MaterializeWorkspaceKitsForPersist(kitsDir string, kitRefs []string, meta *WorkspaceMeta) error

MaterializeWorkspaceKitsForPersist resolves kitRefs (a legacy `kits:` reference list, sourced by the caller — see below) against the kits installed under kitsDir, merging their host_commands (folded in as reference names) and env into meta in place — the exact same expansion MigrateWorkspaceYAMLToDB performs once at cutover (a kit's additional_bindings is read too, but no longer materialized into anything — see materializeKitRuntimeIntoWorkspace's doc comment, Phase 4 PR4).

Phase 2.5 PR7 (docs/plans/workspace-db-consolidation.md, decision 12) removed WorkspaceMeta.Kits outright: this function used to read meta.Kits and clear it after resolving. There is no longer a Kits field to read, so kitRefs is now an explicit parameter the caller must source itself. The only remaining caller is cmd/workspace.go's ensureWorkspaceExistsForAssign (`boid workspace assign`'s auto-create convenience path), which extracts a legacy `kits:` key straight out of the raw on-disk shadow yaml before this call — the wire-level POST/PUT/import bodies no longer accept a kits: key at all (workspaceMetaStrict has no such field any more), so no server-side caller needs this any longer. cmd/project_migrate.go no longer calls this either: its own auto-generated legacy kit's host_commands are folded directly into the workspace meta from the legacy project.yaml's own fields (mergeLegacyFieldsIntoWorkspace), with no kit-directory round trip needed at all — only a *pre-existing, externally referenced* kit (like ensureWorkspaceExistsForAssign's case) needs this function's disk lookup.

This was discovered as a real e2e regression (docker-proxy-* scenarios failing with "$DOCKER_PROXY_TEST_ROOT/docker-proxy-test.sh: not found", exit 127) when `boid workspace assign`'s auto-create path (introduced in PR4) funneled a legacy `kits: [docker-proxy-test]` yaml straight into WorkspaceRepository.Create without this expansion step — the workspaces table has no kits column at all (decision 「kits カラム無し」), so an unmaterialized kit reference would silently vanish on save.

len(kitRefs) == 0 is a fast path: the overwhelming majority of calls (any workspace that never referenced a kit) never touch the filesystem at all.

func MergePayload

func MergePayload(base, update json.RawMessage) (json.RawMessage, error)

func MergePayloadPatch

func MergePayloadPatch(base, patch json.RawMessage, handlerID string, allowedTraits []TraitType) (json.RawMessage, error)

func MigrateWorkspaceYAMLToDB added in v0.0.13

func MigrateWorkspaceYAMLToDB(conn *sql.DB, workspaceDir, kitsDir string, projectRepo *ProjectRepository) error

MigrateWorkspaceYAMLToDB performs the one-time cutover (docs/plans/workspace-db-consolidation.md マイグレーション節 PR3) from yaml-file-authority workspaces (DefaultWorkspaceDir()/*.yaml + kit yaml under kitsDir) to DB-authority workspaces (the `workspaces` table). Call this once at daemon startup, after internal/db/migrate.Apply(conn) has already added the schema_migrations.state/input_hash columns (migration 0031) and the workspaces table (migration 0030).

Idempotent: once schema_migrations records workspace_db_consolidation as state=committed, every subsequent call returns nil immediately without touching the filesystem or the DB.

Crash recovery: if a previous call was interrupted between recording state=staging and reaching state=committed, the next call recomputes the preflight input_hash and compares it against the recorded one — a match rolls forward (safe: every step from here on is an idempotent upsert/atomic-rename), a mismatch aborts with an error (the on-disk inputs changed since the interrupted attempt; automatic reconciliation would risk silently mixing old and new state, so this requires manual intervention).

The old workspace yaml files and kitsDir are never modified or deleted by this function (decision 16: downgrade-by-restoring-the-prior-binary relies on them still being present on disk).

func ProjectLocalPath

func ProjectLocalPath(dir string) string

func RejectPayloadInstructions

func RejectPayloadInstructions(payload json.RawMessage) error

RejectPayloadInstructions returns an error if payload contains an "instructions" top-level key. instructions moved out of payload into Task.Instructions; accepting it here would silently drop it.

func RejectReservedPayloadKeys

func RejectReservedPayloadKeys(payload json.RawMessage) error

RejectReservedPayloadKeys returns an error if the payload contains writes to the artifact.children.* namespace (which is managed by virtual evaluation only).

func RejectTrailingYAMLDocument added in v0.0.13

func RejectTrailingYAMLDocument(dec *yaml.Decoder) error

RejectTrailingYAMLDocument guards against MINOR 2 (codex review round 2, docs/plans/workspace-db-consolidation.md): yaml.Decoder.Decode only ever consumes a single "---"-delimited document per call and silently ignores everything after it — a caller who hand-authors a multi-document workspace yaml (e.g. by copy-pasting a second workspace's fields below a `---` separator, perhaps intending some other tool to consume it) would have the second document silently dropped with no error, and only the first document's fields would ever reach the DB. This calls dec on the same Decoder immediately after the real decode succeeded, expecting io.EOF (nothing left); any other outcome — a second document decoding cleanly, or even a malformed one — is reported as an error rather than silently discarded.

Exported (MAJOR 3, codex review round 1) so cmd/workspace.go's extractLegacyWorkspaceKitRefs can reuse the exact same trailing-document check on the raw local workspace.yaml bytes it reads directly, instead of re-implementing it — see that function's doc comment for why a plain yaml.Unmarshal-into-map there would otherwise defeat this same guard.

func RequireUpstreamURL added in v0.0.10

func RequireUpstreamURL(project *Project) error

RequireUpstreamURL returns an error when project has no upstream_url captured. This is the "既存 project の...欠落 project は...dispatch 時エラー" building block described in docs/plans/git-gateway-cutover.md's "本計画で確定する設計 § 1" — it is intentionally NOT wired into any dispatch path yet. Wiring it in now would reject every current e2e project fixture (none has a real git remote until PR7a's fixture-upstream-server harness lands) ahead of the plan's own PR ordering (PR2 → ... → PR7a → PR6). It is exposed and tested here so PR6 (cutover, where dispatch starts needing upstream_url to build the gateway clone URL) has a ready-made, already covered building block to call.

func ResolveAllowedDomains added in v0.0.8

func ResolveAllowedDomains(globalFloor []string, workspace *WorkspaceMeta) []string

func SetPendingAnswer added in v0.0.7

func SetPendingAnswer(payload json.RawMessage, answer string) json.RawMessage

SetPendingAnswer writes answer into the awaiting trait's pending_answer field while preserving question/question_id and any sibling traits. It is the durable counterpart to the in-memory BlockingAskRegistry delivery: when the answering agent has disconnected (its `boid task ask` foreground command was killed by a harness command-timeout), the answer is parked here so the agent picks it up on its next ask. If the awaiting trait is absent, one is created holding just the answer so it is never silently dropped.

func SetProjectUpstreamURL added in v0.0.10

func SetProjectUpstreamURL(dbtx db.DBTX, id, upstreamURL string) error

SetProjectUpstreamURL updates a project's captured upstream_url (see docs/plans/git-gateway-cutover.md PR2: project → upstream URL mapping). Used by `project add` / `project reload` capture and the daemon-startup backfill for projects registered before this column existed.

func SetProjectWorkspace

func SetProjectWorkspace(dbtx db.DBTX, projectID, workspaceID string) error

SetProjectWorkspace updates a project's local workspace membership.

Domain-layer validation per plan (3-layer defense, last line before DB INSERT). Empty workspaceID clears the membership and bypasses slug validation; any non-empty slug must satisfy ValidWorkspaceSlug so we never persist a malformed identifier even if an upstream layer forgets to check.

func StripAwaitingTrait

func StripAwaitingTrait(payload json.RawMessage) json.RawMessage

StripAwaitingTrait removes the entire awaiting trait from a payload. The awaiting trait is owned exclusively by ApplyAction("ask"/"answer") and the lifecycle, so any value carried in a coordinator's FinalPayload (which snapshots task.Payload before the hook runs) is necessarily stale and must not be merged back over the freshly-persisted DB state on hook completion. Returns the payload unchanged when the awaiting trait is absent.

func TraitBool

func TraitBool(payload json.RawMessage, trait string) bool

TraitBool returns true if the given trait key exists and its value is the JSON literal true. The key may be a dot-separated path (e.g. "lifecycle.executed") for nested object access.

func TraitExists

func TraitExists(payload json.RawMessage, trait string) bool

TraitExists reports whether the dotted-path key resolves to a present, non-null JSON value in payload. Unlike TraitBool it does not require the value to be the JSON literal true; any concrete value (object, array, string, number, true) counts as "exists". Used by state-machine condition rules that gate on the presence of a sibling trait (e.g. `lifecycle.done` set by `done_request`) regardless of its body shape.

func TraitGetString

func TraitGetString(payload json.RawMessage, trait string) (string, bool)

TraitGetString reads the string value at a dotted path. Returns ("", false) if the path is missing or the value is not a JSON string. Used by auto-rule ActionPayloadFns to extract user-facing message text from lifecycle traits.

func UpdateTask

func UpdateTask(dbtx db.DBTX, t *Task) error

func ValidKitName added in v0.0.8

func ValidKitName(s string) error

ValidKitName checks that s is a valid kit name. A valid name consists of lowercase ASCII letters, digits, and hyphens, is between 1 and 64 characters long, and contains no other characters.

func ValidWorkspaceSlug added in v0.0.8

func ValidWorkspaceSlug(s string) error

ValidWorkspaceSlug checks that s is a valid workspace slug. A valid slug consists of lowercase ASCII letters, digits, and hyphens, is between 1 and 64 characters long, and contains no other characters.

func ValidatePayloadPatch

func ValidatePayloadPatch(patch json.RawMessage, allowedTraits []TraitType) error

func WorkspaceExists added in v0.0.13

func WorkspaceExists(dbtx db.DBTX, slug string) (bool, error)

WorkspaceExists reports whether slug has a corresponding row in the workspaces table (MAJOR 5, codex review: SetProjectWorkspace previously assigned any syntactically valid slug without checking it actually existed, leaving a dangling project_workspaces reference — dispatch then runs in a permanently degraded window, and since workspace_db_consolidation's state=committed makes MigrateWorkspaceYAMLToDB a permanent no-op, no later startup ever re-validates and self-heals it).

func WriteHostCommandsConfig added in v0.0.13

func WriteHostCommandsConfig(path string, spec map[string]HostCommandSpec) error

WriteHostCommandsConfig serializes spec as YAML (top-level `host_commands:` map) and atomically writes it to path — a sibling temp file is written, fsynced, and renamed into place (config.WriteFileAtomic), so a reader never observes a partially written file and a write failure never corrupts an existing config. The parent directory is created if it does not exist.

The file is written 0600 (owner read/write only): definitions aggregated from kit.yaml are stored raw/unexpanded (see readKitHostCommandsRaw), but path/env values can still look sensitive, so this config is treated like any other credential-adjacent file on disk.

func WriteProjectLocalMeta

func WriteProjectLocalMeta(dir string, meta *ProjectLocalMeta) error

Types

type AbortReason

type AbortReason struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

AbortReason holds metadata derived from the aborted-state transition action.

type Action

type Action struct {
	ID         string          `json:"id"`
	TaskID     string          `json:"task_id"`
	Type       string          `json:"type"`
	Payload    json.RawMessage `json:"payload"`
	FromStatus TaskStatus      `json:"from_status,omitempty"`
	ToStatus   TaskStatus      `json:"to_status,omitempty"`
	CreatedAt  time.Time       `json:"created_at"`
}

func ListActionsByTask

func ListActionsByTask(dbtx db.DBTX, taskID string) ([]*Action, error)

type AdvanceOutcome

type AdvanceOutcome struct {
	Task          *Task
	ActionPayload json.RawMessage // nil unless the fired rule has ActionPayloadFn
}

AdvanceOutcome carries the result of a successful condition-based transition.

type AwaitingPayload

type AwaitingPayload struct {
	Question      string `json:"question,omitempty"`
	QuestionID    string `json:"question_id,omitempty"`
	PendingAnswer string `json:"pending_answer,omitempty"`
}

AwaitingPayload holds the fields of the "awaiting" trait in task.Payload.

The Q&A flow is now uniformly the blocking RPC `boid task ask`: the agent stays alive inside a broker connection while the daemon waits for a user answer, which is then handed back over the same socket via the in-memory BlockingAskRegistry. There is no session-resume path: the legacy `notify --ask` → `reopen -m` round trip is no longer wired to claude `--resume`, and every dispatch starts a fresh agent process.

Fields written when an `ask` action lands:

  • Question: human-readable question text shown to the user
  • QuestionID: UUID identifying this Q&A turn (for multi-turn tracking)

Fields written by boid (set when the user submits an answer):

  • PendingAnswer: the user's reply (legacy field; the blocking RPC delivers answers in-memory and never sets this, but legacy `notify --ask` paths still surface it as $BOID_USER_ANSWER on the next hook invocation)

SessionID/Mode/Source have been removed: the harness-resume mode they described is gone, and persisted records with those fields deserialize cleanly (encoding/json ignores unknown keys).

func GetAwaitingPayload

func GetAwaitingPayload(payload json.RawMessage) AwaitingPayload

GetAwaitingPayload reads the awaiting trait from raw payload. Returns an empty struct if the trait is absent or malformed.

type BaseBranchState

type BaseBranchState int

BaseBranchState describes the relationship between the project's working directory HEAD and a task's resolved base_branch. The three cases drive Phase 2-2 supervisor execution location routing:

  • Case1HeadMatches: project dir is already on baseBranch → supervisor runs in project dir directly (worktree=false).
  • Case2ExistsButNotCheckedOut: baseBranch exists locally or on origin but project HEAD is on a different branch → supervisor needs a worktree that checks baseBranch out.
  • Case3NotFound: baseBranch is unknown to both local and origin → supervisor needs a worktree whose base branch must be created from the current project HEAD before the worktree can be allocated.

Detached-HEAD projects cannot serve as a sensible default for any of these cases (case 3 in particular needs a real branch to derive from), so classification reports the dedicated detached-HEAD error rather than guessing.

const (
	// BaseBranchStateUnknown is the zero value. It only appears when an error
	// is returned; callers should never act on it directly.
	BaseBranchStateUnknown BaseBranchState = iota
	// Case1HeadMatches: project HEAD == baseBranch. Worktree is unnecessary;
	// the supervisor can run directly in the project dir.
	Case1HeadMatches
	// Case2ExistsButNotCheckedOut: baseBranch resolves to a ref (local or
	// origin/<baseBranch>) but is not what the project HEAD points at.
	Case2ExistsButNotCheckedOut
	// Case3NotFound: baseBranch does not exist locally and origin/<baseBranch>
	// is unknown to the project repo. Callers that own the project dir create
	// the branch from HEAD before allocating a worktree.
	Case3NotFound
)

func ClassifyBaseBranch

func ClassifyBaseBranch(projectDir, baseBranch string) (BaseBranchState, error)

ClassifyBaseBranch inspects the git repository at projectDir to decide how a task whose resolved base_branch is baseBranch should be scheduled. See BaseBranchState for the three case definitions.

baseBranch must already be expanded (no ${...} templates) and non-empty. P1: empty baseBranch is rejected — the service layer must resolve ${current_branch} before calling ClassifyBaseBranch.

The classify call is read-only: no branches are created, no fetches are issued, and the project HEAD is not mutated. Case 3 mitigation (creating the branch) is the caller's responsibility; ClassifyBaseBranch only reports the state.

func (BaseBranchState) String

func (s BaseBranchState) String() string

String makes BaseBranchState printable for log lines / test failure messages. Not part of the public contract — callers should switch on the enum value, not on the rendered string.

type BehaviorResolution

type BehaviorResolution struct {
	BehaviorName string
	Traits       []string
	Readonly     bool
	BranchPrefix string
	BaseBranch   string
	Payload      json.RawMessage
	Instructions Instructions
}

BehaviorResolution holds the resolved behavior fields after processing either a named behavior or an inline behavior_spec.

The Worktree flag was removed in the branch-policy-simplification Phase 2 (docs/plans/branch-policy-simplification.md). Post-cutover every project-visible job runs in a fresh sandbox clone, so per-task worktree isolation is no longer a concept the resolver needs to carry.

func ResolveBehavior

func ResolveBehavior(meta *ProjectMeta, req BehaviorResolveRequest) (*BehaviorResolution, error)

ResolveBehavior validates and resolves behavior fields from a task creation request. It handles both the named behavior path (meta lookup) and the inline behavior_spec path. When both behavior and behavior_spec are empty, the default is resolved via:

  1. meta.DefaultTaskBehavior if set
  2. implicit "supervisor" fallback if that behavior exists in meta (with WARN)
  3. error if neither is available
  4. hardcoded DefaultBehavior when meta is nil (nil-meta paths, e.g. test wiring)

type BehaviorResolveRequest

type BehaviorResolveRequest struct {
	Behavior     string
	BehaviorSpec *BehaviorSpec
	Payload      json.RawMessage
	Instructions json.RawMessage
}

BehaviorResolveRequest carries the behavior-relevant fields from a task creation request.

type BehaviorSpec

type BehaviorSpec struct {
	Name               string       `yaml:"name" json:"name"`
	Traits             []string     `yaml:"traits,omitempty" json:"traits,omitempty"`
	DefaultInstruction *Instruction `yaml:"default_instruction,omitempty" json:"default_instruction,omitempty"`
}

BehaviorSpec is an inline behavior specification that can be used instead of referencing a named behavior from project.yaml task_behaviors. This allows kits to self-describe the behavior they need without depending on project config.

type BindMount

type BindMount struct {
	Source   string `yaml:"source" json:"source"`
	Target   string `yaml:"target,omitempty" json:"target,omitempty"` // if empty, defaults to Source
	Mode     string `yaml:"mode" json:"mode"`                         // "rw" | "" (ro default)
	IsFile   bool   `yaml:"is_file,omitempty" json:"is_file,omitempty"`
	Optional bool   `yaml:"optional,omitempty" json:"optional,omitempty"` // if true, skip mount when Source does not exist on the host
}

BindMount is a plain shared DTO across orchestration and sandbox planning. It carries mount source/target/mode data and does not encode provider behavior.

func (*BindMount) UnmarshalYAML added in v0.0.8

func (b *BindMount) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML accepts two equivalent forms so handwritten and generated kit.yaml can use whichever is more convenient:

additional_bindings:
  - /host/path              # short form: equivalent to {source: "/host/path"}
  - source: /host/path      # struct form: required when mode/target/is_file/etc. are set
    mode: rw

Without this, yaml.v3 rejects the short form with "cannot unmarshal !!str into orchestrator.BindMount" and the single kit's parse error cascades into project meta hydration falling back to raw meta, silently dropping host_commands from *unrelated* kits.

type BuiltinPolicy

type BuiltinPolicy struct {
	AllowedOps      []string
	AllowedCwdRoots []string
}

BuiltinPolicy is the orchestrator-owned, sandbox-agnostic policy type. dispatcher is responsible for translating this into sandbox.BuiltinPolicy before it reaches the broker.

AllowedOps is a sorted slice (rather than a set) so the value is trivially comparable and serialisable across the orchestrator/dispatcher boundary.

func (BuiltinPolicy) Allows

func (p BuiltinPolicy) Allows(op string) bool

Allows reports whether op is in the allowed set.

func (BuiltinPolicy) AllowsCwd

func (p BuiltinPolicy) AllowsCwd(cwd string) bool

AllowsCwd reports whether cwd is within any of the policy's additional cwd roots.

type Capabilities added in v0.0.2

type Capabilities struct {
	// Docker, when non-nil, enables the per-sandbox native docker proxy.
	// Declared as capabilities.docker: {} in project.yaml.
	Docker *DockerCapability `yaml:"docker,omitempty" json:"docker,omitempty"`
}

Capabilities declares optional sandbox capabilities declared in project.yaml.

type CleanupFunc

type CleanupFunc func()

CleanupFunc releases transient resources created while planning a JobSpec (e.g. staging directories for hook scripts). dispatcher invokes it after the sandbox process has exited. A nil CleanupFunc means nothing to release.

type CloneDeclaration added in v0.0.10

type CloneDeclaration struct {
	// Branch is the branch the runner ends up on inside the clone. Always
	// equal to BaseBranch — every task occupies its own BaseBranch directly.
	Branch string

	// BaseBranch is the upstream branch this task's work is based on
	// (task.BaseBranch). Always required.
	BaseBranch string

	// CheckoutOnly is always true (Branch is checked out directly rather than
	// created fresh from a fork point). Kept as an explicit field — rather
	// than collapsed away — so CloneSpec / runner-state.json's declaration
	// shape stays self-describing.
	CheckoutOnly bool

	// BaseBranchForkPoint (ClassifyBaseBranch case 3): the start point used
	// to create BaseBranch locally when it exists on neither the clone's
	// origin nor locally. Empty falls back to refs/remotes/origin/HEAD,
	// resolved by the runner after clone (no extra fetch needed: `git clone`
	// already brings every remote branch's ref). This is independent of the
	// retired per-task fork point: it addresses BaseBranch not existing yet
	// anywhere, not task-to-task isolation.
	BaseBranchForkPoint string
}

CloneDeclaration declares the working-branch state a sandbox-internal clone should end up in, without resolving it — resolution (rev-parse / merge-base / checkout -B) is deferred to the runner, which performs it against the freshly cloned repo (docs/plans/git-gateway-cutover.md: 「dispatcher は JobSpec に宣言のみ載せる...runner が clone 完了後に解決」).

docs/plans/branch-policy-simplification.md Phase 1 retired the per-task "boid/<id8>" branch and its fork-point: every task, root or child, checks out BaseBranch directly (CheckoutOnly is always true now). See BuildCloneDeclaration's doc comment for the rationale.

func BuildCloneDeclaration added in v0.0.10

func BuildCloneDeclaration(task *Task, baseBranchForkPoint string) *CloneDeclaration

BuildCloneDeclaration derives the sandbox-internal clone branch declaration for a task (docs/plans/git-gateway-cutover.md PR6 cutover; docs/plans/branch-policy-simplification.md Phase 1). Dispatcher no longer runs any git command against a host repo — it just carries this declaration through to the sandbox, and the runner resolves it for real after cloning (docs/plans/git-gateway-cutover.md 「5. branch 宣言の JobSpec 化」).

Every task — root or child, worktree or not — checks out task.BaseBranch directly inside its sandbox-internal clone (CheckoutOnly is always true now). The per-task "boid/<id8>" branch and its fork-point, worktree-era concepts are retired here: they existed only to let a child task continue from a parent's uncommitted work on a shared .git. Cutover's independent-fresh-clone-per-job model makes that both impossible (a fresh clone only ever sees origin's already-pushed refs — an uncommitted parent branch is invisible to it) and unnecessary (the clone itself is now the isolation unit, so no two tasks ever contend for the same branch name inside their own sandbox). See docs/plans/branch-policy-simplification.md for the full rationale and the readonly-supervisor design mismatch this removal fixes.

baseBranchForkPoint is ProjectMeta.ForkPoint (the ClassifyBaseBranch case-3 fork point: the start point used to create task.BaseBranch locally when it exists on neither origin nor locally yet), passed straight through to CloneDeclaration.BaseBranchForkPoint. This is a separate, independent axis from the retired per-task fork point described above — see CloneDeclaration.BaseBranchForkPoint's doc comment.

type CommandDef

type CommandDef struct {
	Name               string            `json:"name,omitempty"`
	Path               string            `json:"path,omitempty"`
	AllowedPatterns    []string          `json:"allowed_patterns,omitempty"`
	DeniedPatterns     []string          `json:"denied_patterns,omitempty"`
	AllowedSubcommands []string          `json:"allowed_subcommands,omitempty"`
	Env                map[string]string `json:"env,omitempty"`
	RejectRules        []RejectRule      `json:"reject_rules,omitempty"`
}

CommandDef is the orchestrator-side transport shape for sandbox command policy input. Dispatcher and sandbox mirror this shape; sandbox owns the enforcement semantics.

type Coordinator

type Coordinator struct {
	Evaluator      *Evaluator
	HookExecutor   HookExecutor
	Waiter         JobWaiter
	MaxDepth       int
	LifecycleStore LifecycleStore // optional; nil skips rework_count/abort derivation
}

Coordinator orchestrates the hook → advance flow.

Locking is owned by the workflow service for the entire executing lifetime of each task (see internal/api/service.go). The coordinator dispatches hooks under the assumption that the branch lock is already held by the caller.

func (*Coordinator) DispatchAndAdvance

func (d *Coordinator) DispatchAndAdvance(
	ctx context.Context,
	task *Task,
	meta *ProjectMeta,
	sm *StateMachine,
) (*DispatchResult, error)

DispatchAndAdvance runs the full dispatch cycle: 1. Evaluate and execute hooks (parallel if readonly, sequential otherwise) 2. Merge hook payload patches 3. Evaluate condition-based auto-advance via state machine

func (*Coordinator) ReplayHook

func (d *Coordinator) ReplayHook(
	ctx context.Context,
	task *Task,
	meta *ProjectMeta,
	sm *StateMachine,
	hookID string,
) (*ReplayResult, error)

ReplayHook executes a single named hook in isolation against the task's current state. After the hook completes, lifecycle is derived and sm.Advance is applied — identical to the post-hook phase of DispatchAndAdvance.

Returns an error if the hook ID is not found in the behavior or if the hook does not match the current status (e.g. task not in executing state).

type DBProjectCatalog

type DBProjectCatalog struct {
	DB db.DBTX
}

func (DBProjectCatalog) GetProject

func (c DBProjectCatalog) GetProject(id string) (*Project, error)

func (DBProjectCatalog) ListProjects

func (c DBProjectCatalog) ListProjects() ([]*Project, error)

type DBTaskLookup

type DBTaskLookup struct {
	DB db.DBTX
}

func (DBTaskLookup) GetTask

func (l DBTaskLookup) GetTask(id string) (*Task, error)

type DispatchPlanner

type DispatchPlanner struct {
	Meta     MetaCache
	Hydrator MetaHydrator // optional; when set, loadContext uses GetWithWorkspace instead of Get
	Projects ProjectCatalog
	Tasks    TaskLookup
	Adapter  adapters.HarnessAdapter
}

DispatchPlanner turns state-machine-driven hook fire events into a sandbox-agnostic JobSpec. All sandbox construction concerns (mounts, env, proxy wiring, exit scripts, worktree recreation) live in dispatcher.

func WireDispatchPlanner

func WireDispatchPlanner(cfg PlannerWireConfig) *DispatchPlanner

func (*DispatchPlanner) PlanHook

func (p *DispatchPlanner) PlanHook(event *HookFireEvent) (*JobSpec, CleanupFunc, error)

PlanHook renders a hook fire event into a JobSpec.

Agent-kind hooks (Hook.Kind == HandlerKindAgent) may omit Command — the HarnessAdapter builds its own argv from CLI conventions, so an empty Argv flows through fine. Non-agent hooks (shell-bound) require a resolved Command. The Evaluator may synthesize a command-less agent hook when the behavior declares none of its own (Phase 3-e kit-retirement fallback); the relaxed validation here is what makes those virtual hooks dispatch-ready.

Command is an inline shell command (docs/plans/script-hook-removal.md): see validateHookCommandFields for the exclusivity rules between Command / Agent / Kind.

type DispatchResult

type DispatchResult struct {
	Results     []HandlerResult
	FiredEvents []FiredEvent
	// FinalPayload is the FULL payload this cycle computed: the task's
	// payload AS IT WAS AT THE START of this dispatch cycle (a snapshot that
	// can go stale the instant a long-running hook job starts, since the
	// hook itself may write to the task's payload mid-flight — e.g. via
	// `boid task update --payload-patch`/`--payload-file`), with this
	// cycle's own hook PayloadPatches merged on top. It is what
	// evaluateAndAdvance's state-machine Advance decision is based on; it
	// must NOT be used to persist the task's payload (see PayloadDelta).
	FinalPayload json.RawMessage
	// PayloadDelta is the SAME hook-patch merge as FinalPayload, but folded
	// starting from an empty object instead of the (potentially stale)
	// starting snapshot — i.e. only what THIS cycle's own hooks actually
	// wrote, never the pre-existing payload they didn't touch. Callers that
	// persist a dispatch cycle's result (runDispatchLoop, ReplayHook's
	// caller) must apply THIS to a freshly re-read task row, not
	// FinalPayload: applying the full stale snapshot on top of a fresh row
	// silently reverts any out-of-band write the hook itself made during
	// its own run (Phase 5b PR7 codex review Blocker 1, wiring-seams.md
	// #17) — e.g. a reopened task's `--payload-patch` report write getting
	// clobbered back to the pre-reopen value once the hook completes and
	// this cycle's post-hook persist runs. Empty ("{}") when no hook in
	// this cycle wrote anything, which is the common case for an agent job
	// that reports exclusively through the direct RPC paths — applying an
	// empty delta onto a fresh row is a correct no-op (orchestrator.
	// MergePayload's own empty-update short-circuit returns base
	// unchanged), so a stale snapshot never even gets a chance to overwrite
	// anything.
	PayloadDelta  json.RawMessage
	NewStatus     TaskStatus      // set if orchestrator advanced the state
	ActionPayload json.RawMessage // optional payload to attach to the auto_advance action
}

DispatchResult is the accumulated result of a full dispatch cycle.

type DockerCapability added in v0.0.2

type DockerCapability struct{}

DockerCapability is the opt-in marker for the native docker proxy. Presence (non-nil pointer in Capabilities) enables the proxy; the empty struct is a placeholder for future per-project policy fields.

type DoneReport

type DoneReport struct {
	Message string `json:"message"`
}

DoneReport carries the message the agent reported via `notify --done`.

type Evaluator

type Evaluator struct{}

func (*Evaluator) Evaluate

func (e *Evaluator) Evaluate(task *Task, hooks []Hook) []Hook

Evaluate returns hooks that should fire for the given task. Hooks fire only during executing state. Hooks with Kind == HandlerKindAgent additionally require an instruction in task.Instructions addressed to that hook's Agent.

Phase 3-e fallback: when the behavior declares no agent-kind hook at all (typical after the boid-kits claude-code/codex retirement landed in PR #604), the evaluator synthesizes a virtual agent-kind hook for the active instruction's agent. The runner-inner-child hands every agent-kind job to its HarnessAdapter directly, so a hook with no Argv is dispatch-ready — see planner.PlanHook and adapters.HarnessAdapter.Run. The synthesis is gated to known harness agents (claude-code / codex / opencode) so unknown agent names do not collide with the shell adapter's Argv requirement.

type FailReport

type FailReport struct {
	Message string `json:"message"`
}

FailReport carries the message the agent reported via `notify --fail`.

type FiredEvent

type FiredEvent struct {
	KitID       string // kit that owns this handler; empty for project-local
	HandlerID   string // hook ID
	JobID       string // ID of the job that executed this handler
	Kind        string // "hook" or "hook_replay"
	SourceState string // task status at the time of dispatch
	Success     bool
	Error       string
}

FiredEvent records a single hook execution for action logging.

type GCLoop

type GCLoop struct {
	Store        GCStore
	Interval     time.Duration
	OlderThan    time.Duration
	InitialDelay time.Duration
}

GCLoop periodically calls GC on a GCStore.

func (*GCLoop) Run

func (l *GCLoop) Run(ctx context.Context)

Run blocks until ctx is done. It waits InitialDelay before the first GC run, then calls Store.GC every Interval. Errors are logged as warnings; the loop always continues.

type GCResult

type GCResult struct {
	Tasks      int64
	Jobs       int64
	Actions    int64
	Runtimes   int64
	SandboxTmp int64 // leaked /tmp/boid-* sandbox artifacts removed
	Devices    int64 // revoked web devices deleted
}

GCResult holds the count of records affected by GC.

func GCTasks

func GCTasks(dbtx db.DBTX, statuses []string, olderThan time.Duration, dryRun bool) (*GCResult, error)

GCTasks deletes terminal tasks older than olderThan and their related data (actions, jobs). If dryRun is true, counts only without deleting. olderThan=0 disables the time filter (all matching tasks are affected). Must be called within a transaction for atomicity.

type GCStore

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

GCStore is the interface required by GCLoop to run garbage collection.

type HandlerKind

type HandlerKind string

HandlerKind distinguishes the role a hook plays. An empty kind means a generic hook (no instructions routing). Only agent-kind hooks participate in instructions routing.

const (
	HandlerKindAgent HandlerKind = "agent"
)

func (HandlerKind) IsValid

func (k HandlerKind) IsValid() bool

IsValid reports whether the kind value is recognized.

type HandlerResult

type HandlerResult struct {
	ID           string // hook ID
	Role         Role
	JobID        string // ID of the job that executed this handler
	ExitCode     int
	PayloadPatch json.RawMessage
}

HandlerResult is the result of a single hook execution.

type HandlerTraits

type HandlerTraits struct {
	Consumes []TraitType `json:"consumes,omitempty" yaml:"consumes,omitempty"`
	Produces []TraitType `json:"produces,omitempty" yaml:"produces,omitempty"`
}

type Hook

type Hook struct {
	ID       string        `yaml:"id" json:"id"`
	Name     string        `yaml:"name,omitempty" json:"name,omitempty"`
	Kind     HandlerKind   `yaml:"kind,omitempty" json:"kind,omitempty"`
	Traits   HandlerTraits `yaml:"traits" json:"traits"`
	Requires []string      `yaml:"requires" json:"requires"`
	Agent    string        `yaml:"agent,omitempty" json:"agent,omitempty"`
	Kit      string        `yaml:"-" json:"kit,omitempty"`
	// Command is an inline shell command, run via `sh -c`
	// (docs/plans/script-hook-removal.md). Mutually exclusive with Agent, and
	// not allowed on agent-kind hooks. See DispatchPlanner.PlanHook for the
	// argv selection and validateHookCommandFields for the exclusivity rules.
	Command string `yaml:"command,omitempty" json:"command,omitempty"`
}

func ListHooksForStatus

func ListHooksForStatus(meta *ProjectMeta, task *Task, status TaskStatus) []Hook

ListHooksForStatus returns hooks from the behavior that would match the given status. Since hooks only fire during executing, only executing status yields results. Used by "boid task hook list".

func (*Hook) UnmarshalYAML

func (h *Hook) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML rejects legacy `on:` entries to surface migration breakage clearly.

type HookExecutor

type HookExecutor interface {
	ExecuteHook(ctx context.Context, event *HookFireEvent) (jobID string, err error)
}

HookExecutor launches a hook and returns the job ID.

type HookFireEvent

type HookFireEvent struct {
	EventID   string
	TaskID    string
	ProjectID string
	Hook      Hook
}

type HostCommandSpec

type HostCommandSpec struct {
	Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
	Deny  []string `yaml:"deny,omitempty" json:"deny,omitempty"`
	// Stdin is deprecated: it is still parsed for backward compatibility but
	// will be ignored in a future release (loading a spec with stdin: true
	// emits a deprecation warning).
	Stdin  bool              `yaml:"stdin,omitempty" json:"stdin,omitempty"`
	Path   string            `yaml:"path,omitempty" json:"path,omitempty"`
	Env    map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
	Reject []RejectRule      `yaml:"reject,omitempty" json:"reject,omitempty"`
}

HostCommandSpec is the simplified YAML DSL for declaring host commands.

func (HostCommandSpec) ToCommandDef

func (s HostCommandSpec) ToCommandDef(name string) CommandDef

ToCommandDef converts a HostCommandSpec into a CommandDef for internal use.

type HostCommands

type HostCommands map[string]HostCommandSpec

HostCommands supports both list and map YAML forms:

host_commands: [gh, aws]
host_commands:
  gh:
    allow: [pr, issue]
  aws:

func (HostCommands) ToCommandDefs

func (h HostCommands) ToCommandDefs() map[string]CommandDef

ToCommandDefs converts the DSL specs to internal CommandDef map.

func (*HostCommands) UnmarshalYAML

func (h *HostCommands) UnmarshalYAML(value *yaml.Node) error

type Instruction

type Instruction struct {
	Agent   string `json:"agent" yaml:"agent"`
	Name    string `json:"name,omitempty" yaml:"name,omitempty"`
	Message string `json:"message,omitempty" yaml:"message,omitempty"`
	Model   string `json:"model,omitempty" yaml:"model,omitempty"`
}

type Instructions

type Instructions []Instruction

Instructions is the persisted instruction history for a task. The most recent entry is the "active" instruction passed to the agent on dispatch; earlier entries are kept as history (e.g. for reopen tracking).

JSON shape on the wire is an array. For backward compatibility, the legacy single-instruction map form ({"main": {...}}) is also accepted on unmarshal and converted to a single-element array.

func AppendInstruction

func AppendInstruction(existing Instructions, inst Instruction) Instructions

AppendInstruction returns a new instruction list with `inst` appended. The caller is responsible for filling in fields not derived from the previous active entry. Used by `boid task reopen` to record a new context message while preserving history.

func MergeDefaultInstructions

func MergeDefaultInstructions(defaultInstruction *Instruction, requestInstructions json.RawMessage) (Instructions, error)

MergeDefaultInstructions builds the initial instruction list for a new task.

Merge semantics:

  • defaultInstruction == nil: use requestInstructions as-is (no base to inherit from).
  • defaultInstruction != nil:
  • override empty/null/[]/{} → return the default as a single-entry list.
  • override has exactly 1 entry → per-field merge: non-empty fields from the override win; empty fields inherit from defaultInstruction.
  • override has 2+ entries → complete replacement (caller is building an explicit history and partial merge would be ambiguous).

requestInstructions accepts the array form `[{...}, ...]` and the legacy single-map form `{"main": {...}}` (handled by Instructions.UnmarshalJSON).

func (Instructions) Active

func (is Instructions) Active() *Instruction

Active returns the currently-active instruction (the last entry), or nil if the list is empty.

func (*Instructions) UnmarshalJSON

func (is *Instructions) UnmarshalJSON(data []byte) error

UnmarshalJSON accepts both the new array form and the legacy {"main": {...}, "verify": {...}} map form. For the map form, only the "main" entry is preserved (verifying/reworking variants were removed).

type JobCompletion

type JobCompletion struct {
	JobID    string
	Output   string // stdout capture or payload_patch.json content
	ExitCode int
}

JobCompletion represents the result of a completed job.

type JobKind

type JobKind string

JobKind is a DB / observability label for a job. It is NOT a sandbox construction signal — dispatcher's mount/env logic must derive everything from Visibility / HostCommands / Instruction, never from Kind. Kind exists purely so the Job row / TUI can tell apart "agent hook" and "user-initiated exec" without re-deriving the category from loosely-related primitives like HandlerID.

const (
	JobKindHook JobKind = "hook"
	JobKindExec JobKind = "exec"
	// JobKindSession is the Phase 3-d label for a user-initiated agent
	// session not tied to a task lifecycle (WebUI [New Session] / `boid
	// agent` CLI). It is distinct from JobKindHook (state-machine driven)
	// and JobKindExec (sandboxed argv with no harness), so the TUI / Web UI
	// can present it as a separate top-level concept.
	JobKindSession JobKind = "session"
)

type JobSpec

type JobSpec struct {
	// Identity used by dispatcher for Job DB persistence and state-machine
	// notification. TaskID and HandlerID are empty for boid-exec jobs.
	TaskID      string
	ProjectID   string
	HandlerID   string
	DisplayName string // human-readable label (hook name or command-session name); persisted to jobs.display_name

	// HookTraitsProduces captures the firing hook's own `traits.produces`
	// list (event.Hook.Traits.Produces) AT DISPATCH TIME — the exact value
	// HandlerResult.allowedTraits (coordinator.go) would use to gate this
	// same hook's file-based payload_patch merge. Phase 5b PR7's
	// `boid task update --payload-patch` RPC (docs/plans/
	// phase5-shim-and-task-context.md) threads this through
	// dispatcher.JobContextSnapshot so it can gate its own merge against
	// the SAME dispatch-time value, never a live re-lookup against the
	// (possibly since-edited/reloaded) project meta — codex review caught a
	// TOCTOU staleness bug in an early cut that re-resolved the hook by ID
	// against current meta at merge time (wiring-seams.md #17's Major 1).
	// nil means "this job's firing hook produces unrestricted" — true for
	// both a virtual/synthesized agent-kind hook (orchestrator.
	// synthesizeAgentHook, whose Traits are always the zero value — the
	// common case for a behavior with no explicit `hooks:` block) and an
	// explicitly declared hook with no traits.produces list of its own;
	// both are indistinguishable from "unrestricted" on the file-based path
	// too (MergePayloadPatch(patch, nil) never filters), so this preserves
	// that behavior exactly rather than inventing a new distinction. Empty
	// for non-hook jobs (boid exec).
	HookTraitsProduces []TraitType

	// Kind is a DB-label / TUI-display category. Sandbox construction details
	// MUST NOT branch on this value.
	Kind JobKind

	// Argv is the command to execute. Argv[0] is either a host absolute path
	// (hook scripts) or a bare command name resolved via broker shim
	// (boid exec). Everything after Argv[0] is passed as-is.
	Argv []string

	// Instruction, when non-nil, materializes at $HOME/.boid/context/instructions.yaml
	// and identifies the agent that should pick up the job. Agent-less jobs
	// (boid exec) leave this nil.
	Instruction *RoutedInstruction

	// Task, when non-nil, materializes at $HOME/.boid/context/task.yaml.
	// boid-exec jobs always leave it nil.
	Task *TaskSnapshot

	// PrimaryInput is the payload the script expects to read on stdin (or,
	// for agent jobs, at $HOME/.boid/context/payload.json).
	// nil means the job reads nothing from boid (e.g. boid-exec with a user TTY).
	PrimaryInput json.RawMessage

	// Visibility describes which host directories the sandbox sees and whether
	// they are writable.
	Visibility Visibility

	// BuiltinPolicies authorises broker-mediated builtin operations (boid, git).
	BuiltinPolicies map[string]BuiltinPolicy

	// HostCommands authorises broker-mediated host command invocations.
	// Hook jobs leave this empty; exec jobs populate it from behavior.
	HostCommands map[string]CommandDef

	// SecretNamespace scopes the broker's secret resolver.
	SecretNamespace string

	// Env carries extra environment variables the orchestrator wants to export
	// (e.g. behavior-level overrides). dispatcher merges these with its own
	// HOME/PATH/proxy/broker settings.
	Env map[string]string

	// ExecutionState records the task.Status at the time this job was dispatched.
	// Stored in the job DB row so TUI can reconstruct replay context.
	ExecutionState string

	// Interactive, when true, forces TTY allocation regardless of whether an
	// Instruction is attached. Used by daemon-side command execution (Web UI)
	// where the caller always expects a PTY-backed terminal.
	Interactive bool

	// HarnessType identifies which HarnessAdapter implementation the runner
	// hands the agent process off to. Phase 3-d made this invariant
	// non-empty for every dispatched job:
	//   - "shell" for hooks without an `agent:` declaration, every `boid
	//     exec`, and the fall-through for unknown agents
	//   - "claude" / "codex" / "opencode" for the corresponding agent hooks
	//     and user-initiated sessions
	// dispatcher bridges this into sandbox.Spec.HarnessType and the
	// runner-inner-child resolves the adapter via the registry.
	HarnessType string

	// SandboxProfile selects the filesystem layout strategy for the sandbox.
	// Zero value (sandbox.ProfileDefault) preserves existing behaviour.
	// Set to sandbox.ProfileInit for kit-init / workspace-configure generation
	// scripts that need read access to the full host filesystem.
	// When ProfileInit is set, broker registration and the broker socket mount
	// are both skipped.
	SandboxProfile int // sandbox.Profile — kept as int to avoid a circular import
}

JobSpec is the orchestrator-owned, sandbox-agnostic execution request. It is written purely in business vocabulary: "what to run with what visibility and permissions". All sandbox construction details (mounts, env, exit scripts, proxy wiring, stdin routing) are left to dispatcher.

dispatcher is the only layer that bridges JobSpec and sandbox.Spec.

type JobWaiter

type JobWaiter interface {
	WaitForJob(ctx context.Context, jobID string) (JobCompletion, error)
}

JobWaiter waits for a job to complete.

type KitRef

type KitRef struct {
	Ref string `yaml:"ref" json:"ref"`
}

func (*KitRef) UnmarshalYAML

func (k *KitRef) UnmarshalYAML(value *yaml.Node) error

type LegacyProjectMeta added in v0.0.8

type LegacyProjectMeta struct {
	ID                  string                        `yaml:"id"`
	Name                string                        `yaml:"name"`
	Kits                []KitRef                      `yaml:"kits,omitempty"`
	TaskBehaviors       map[string]LegacyTaskBehavior `yaml:"task_behaviors,omitempty"`
	BaseBranch          string                        `yaml:"base_branch,omitempty"`
	ForkPoint           string                        `yaml:"fork_point,omitempty"`
	HostCommands        HostCommands                  `yaml:"host_commands,omitempty"`
	Env                 map[string]string             `yaml:"env,omitempty"`
	SecretNamespace     string                        `yaml:"secret_namespace,omitempty"`
	Capabilities        Capabilities                  `yaml:"capabilities,omitempty"`
	DefaultTaskBehavior string                        `yaml:"default_task_behavior,omitempty"`

	// HadAdditionalBindingsKey records only whether the raw project.yaml (or
	// project.local.yaml) still declares a top-level `additional_bindings:`
	// key — it is NOT unmarshaled from that key itself (yaml:"-"), and no
	// value is ever kept. Before docs/plans/home-workspace-volume.md Phase 4
	// PR4, this type had a typed `AdditionalBindings []BindMount` field: an
	// old project.yaml's additional_bindings migrated into an auto-generated
	// "legacy kit" and from there into the target workspace's own (now also
	// retired) AdditionalBindings field. Both destinations are gone, so the
	// value itself is no longer worth carrying — but computeRemoveKeys
	// (cmd/project_migrate.go) still needs to know whether the key was
	// present at all, so `boid project migrate` keeps offering to strip it
	// from project.yaml (which rejects the key outright — see
	// removedTopLevelKeys in spec_loader.go) even though nothing downstream
	// resolves its value any more. See ReadProjectMetaLegacy for how this is
	// populated.
	HadAdditionalBindingsKey bool `yaml:"-"`
}

LegacyProjectMeta holds the raw contents of a pre-migration project.yaml, including fields that have been moved to workspace.yaml and kit.yaml in the new schema. This type is used only by the migrate command; normal loading goes through ReadProjectMeta.

func ReadProjectMetaLegacy added in v0.0.8

func ReadProjectMetaLegacy(dir string) (*LegacyProjectMeta, error)

ReadProjectMetaLegacy reads .boid/project.yaml (and .boid/project.local.yaml if present) using a raw-map first pass that does NOT reject unknown or removed fields. This is intentionally separate from ReadProjectMeta so that the migrate command can load old-schema files that would otherwise fail validation.

The returned LegacyProjectMeta captures all fields that are subject to migration, including kits/env/host_commands/secret_namespace/capabilities at both the project level and the task_behaviors level (additional_bindings is detected but not carried as a value any more — see LegacyProjectMeta.HadAdditionalBindingsKey).

func (*LegacyProjectMeta) CollectAllKitRefs added in v0.0.8

func (m *LegacyProjectMeta) CollectAllKitRefs() []KitRef

CollectAllKitRefs returns a deduplicated, ordered list of all kit refs referenced at the project level and across all task behaviors.

func (*LegacyProjectMeta) HasLegacyFields added in v0.0.8

func (m *LegacyProjectMeta) HasLegacyFields() bool

HasLegacyFields reports whether the LegacyProjectMeta contains any fields that should be migrated to workspace.yaml or a legacy kit. This is used by the migrate command to detect whether migration is needed at all.

A stray additional_bindings key deliberately does NOT make this return true on its own (Phase 4 PR4, docs/plans/home-workspace-volume.md: the field is retired, there is nothing left to migrate its value to) — but computeRemoveKeys (cmd/project_migrate.go) still offers to strip the key from project.yaml via HadAdditionalBindingsKey independently of this method, so printMigratePlan's "nothing to migrate" short-circuit (which also checks len(removeKeys) == 0) still correctly falls through to show a plan for a project.yaml that has nothing BUT a stray additional_bindings key left.

type LegacyTaskBehavior added in v0.0.8

type LegacyTaskBehavior struct {
	Readonly           *bool        `yaml:"readonly,omitempty"`
	Traits             []string     `yaml:"traits,omitempty"`
	DefaultInstruction *Instruction `yaml:"default_instruction,omitempty"`
	Kits               []KitRef     `yaml:"kits,omitempty"`
}

LegacyTaskBehavior holds a task behavior including the legacy kits field that appears at the behavior level in old project.yaml files.

type Lifecycle

type Lifecycle struct {
	Executed bool         `json:"executed"`
	Done     *DoneReport  `json:"done,omitempty"`
	Fail     *FailReport  `json:"fail,omitempty"`
	Abort    *AbortReason `json:"abort,omitempty"`
}

Lifecycle holds computed lifecycle traits derived from action history and the current dispatch state. It is never persisted to the payload; it is injected transiently before state-machine evaluation.

Done / Fail carry the agent's intent reported via `notify --done` / `notify --fail` (recorded as `done_request` / `fail_request` actions). They gate the executing→done / executing→aborted auto-advance rules. By keeping these as derived traits (rather than firing an immediate state transition inside NotifyTask), the runtime is allowed to exit cleanly via SIGUSR1 → bash EXIT trap → `boid job done` before the dispatch loop advances the state. See docs/plans/lifecycle-accountability.md (Phase 2.c follow-up).

func DeriveLifecycle

func DeriveLifecycle(_ context.Context, taskID string, store LifecycleStore, hookExecuted bool) (Lifecycle, error)

DeriveLifecycle computes lifecycle traits from action history. hookExecuted indicates whether a hook job completed successfully in the current dispatch cycle (used to set lifecycle.executed). If store is nil, only Executed is set from hookExecuted.

Done / Fail are scoped to the current executing cycle: every time the task (re-)enters executing (start / answer / reopen) the prior intent is cleared, so a `done_request` recorded before a `reopen` does not bleed into the new cycle. done_request / fail_request are mutually exclusive — recording one clears the other.

type LifecycleStore

type LifecycleStore interface {
	ListActionsByTask(taskID string) ([]*Action, error)
}

LifecycleStore is the minimal interface required to derive lifecycle traits from persistent action history.

type MergeMode

type MergeMode string
const (
	MergeModeExclusive MergeMode = "exclusive"
	MergeModeShared    MergeMode = "shared"
)

func TraitMergeMode

func TraitMergeMode(trait TraitType) MergeMode

type MetaCache

type MetaCache interface {
	Get(id string) (*ProjectMeta, bool)
}

type MetaHydrator added in v0.0.8

type MetaHydrator interface {
	GetWithWorkspace(ctx context.Context, projectID string) (*ProjectMeta, error)
}

MetaHydrator extends MetaCache with workspace-aware hydration. When a ProjectStore has a WorkspaceStore configured, it implements this interface and returns a ProjectMeta enriched with workspace capabilities/kits/env.

type PlannerWireConfig

type PlannerWireConfig struct {
	Meta     MetaCache
	Hydrator MetaHydrator // optional; when set, dispatch uses GetWithWorkspace
	Projects ProjectCatalog
	Tasks    TaskLookup
	Adapter  adapters.HarnessAdapter
}

type PolicyContext

type PolicyContext struct {
	ProjectDir string
	HomeDir    string
}

PolicyContext carries non-role data needed to compute role-derived policies. ProjectDir lets boid policy accept the host project dir as cwd. HomeDir accepts the sandbox HOME, which is the default WorkDir for hook jobs.

type Project

type Project struct {
	ID          string      `json:"id"`
	WorkspaceID string      `json:"workspace_id"`
	WorkDir     string      `json:"work_dir"`
	Meta        ProjectMeta `json:"meta"`
	// UpstreamURL is the project's git remote origin, captured and normalized
	// to HTTPS (SSH → HTTPS) at `project add` / `project reload` time (see
	// docs/plans/git-gateway-cutover.md PR2). Empty until captured; daemon
	// startup backfills it for projects registered before this field existed.
	// Not read from project.yaml — this is DB-only, machine-local state.
	UpstreamURL string    `json:"upstream_url,omitempty"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

func GetProject

func GetProject(dbtx db.DBTX, id string) (*Project, error)

GetProject retrieves a project by ID.

func ListProjects

func ListProjects(dbtx db.DBTX) ([]*Project, error)

ListProjects returns all projects ordered by creation time.

type ProjectCatalog

type ProjectCatalog interface {
	GetProject(id string) (*Project, error)
}

type ProjectLocalMeta

type ProjectLocalMeta struct {
	Version            int               `yaml:"version"`
	HostCommands       HostCommands      `yaml:"host_commands,omitempty"`
	AdditionalBindings []BindMount       `yaml:"additional_bindings,omitempty"`
	Env                map[string]string `yaml:"env,omitempty"`
	SecretNamespace    string            `yaml:"secret_namespace,omitempty"`
}

func NewProjectLocalMeta

func NewProjectLocalMeta() *ProjectLocalMeta

func ReadProjectLocalMeta

func ReadProjectLocalMeta(dir string) (*ProjectLocalMeta, error)

type ProjectMeta

type ProjectMeta struct {
	ID            string                  `yaml:"id" json:"id"`
	Name          string                  `yaml:"name" json:"name"`
	TaskBehaviors map[string]TaskBehavior `yaml:"task_behaviors" json:"task_behaviors"`
	// BaseBranch is the default git base branch for worktrees created by
	// tasks in this project. It is resolved at task creation time (with
	// ${TASK_REMOTE_ID} / ${current_branch} expansion) and persisted on
	// each task row.
	BaseBranch string `yaml:"base_branch,omitempty" json:"base_branch,omitempty"`
	// ForkPoint is the git ref used as the start point when creating a
	// base branch that does not yet exist (ClassifyBaseBranch case 3).
	// Accepts any ref that `git rev-parse --verify` resolves (e.g. "main",
	// "origin/main", a tag, or a commit SHA). When empty, the dispatcher
	// falls back to "refs/remotes/origin/HEAD"; if that is also unset the
	// case-3 worktree creation fails. The project root's working-tree HEAD
	// is intentionally never consulted, since it can drift to an
	// unexpected branch between task creation and dispatch.
	ForkPoint          string            `yaml:"fork_point,omitempty" json:"fork_point,omitempty"`
	HostCommands       HostCommands      `yaml:"host_commands" json:"host_commands"`
	AdditionalBindings []BindMount       `yaml:"additional_bindings" json:"additional_bindings"`
	Env                map[string]string `yaml:"env" json:"env"`
	// SecretNamespace is a runtime-only field injected at hydration time from the
	// linked workspace ID. It is intentionally not read from project.yaml (yaml:"-").
	SecretNamespace string `yaml:"-" json:"secret_namespace,omitempty"`
	// Capabilities declares optional sandbox capabilities. This is a runtime-only
	// field injected from workspace.yaml at hydration time (yaml:"-").
	Capabilities Capabilities `yaml:"-" json:"capabilities,omitempty"`
	// DefaultTaskBehavior names the behavior to use when a CreateTaskRequest
	// omits both behavior and behavior_spec. When empty, the daemon falls back
	// to "supervisor" if that behavior exists (with a deprecation warning);
	// if neither is set, CreateTask returns an error.
	DefaultTaskBehavior string `yaml:"default_task_behavior,omitempty" json:"default_task_behavior,omitempty"`
}

func ReadProjectMeta

func ReadProjectMeta(dir string) (*ProjectMeta, error)

func ReadProjectMetaWithKits

func ReadProjectMetaWithKits(dir string) (*ProjectMeta, error)

ReadProjectMetaWithKits reads project.yaml and merges project-level overlays into each behavior. Returns a ProjectMeta whose TaskBehaviors have their resolved Hooks/etc. populated and ready for dispatch.

Note: kits are no longer supported in project.yaml (removed in the new schema); the kit mechanism itself was retired in Phase 2.5 PR6 (docs/plans/workspace-db-consolidation.md). This function used to accept a KitResolver parameter for call-site compatibility even though it was never used; PR7 removed that type and parameter outright (decision 12). The "WithKits" name is now a historical artifact — it's kept as-is since it's a widely-used, purely-internal function name, not worth a rename-only churn across every call site. project.local.yaml is deprecated; use workspace.yaml instead.

type ProjectMigrationError added in v0.0.8

type ProjectMigrationError struct {
	Projects []ProjectMigrationIssue
}

ProjectMigrationError is a typed error carrying one or more migration issues. Callers (e.g. boid start) use errors.As to extract the issues and drive auto-migration; Error() preserves the legacy multi-line format so log output (boid.log) is byte-identical to the pre-typed-error version.

func (*ProjectMigrationError) Error added in v0.0.8

func (e *ProjectMigrationError) Error() string

Error returns the multi-line, human-readable error message. The format for a single issue is byte-identical to the legacy rejectRemovedProjectFields output so existing spec_loader tests pass unchanged. Multiple issues are separated by a single newline.

type ProjectMigrationIssue added in v0.0.8

type ProjectMigrationIssue struct {
	// ProjectID is the registered project ID, when known. Empty when the
	// issue surfaces from a loader path that does not yet have a DB lookup
	// (e.g. ReadProjectMeta called directly with a path).
	ProjectID string
	// Dir is the absolute path to the project root (parent of .boid/).
	Dir string
	// Messages are the per-field violation messages in the same order as
	// the old single-string error produced by rejectRemovedProjectFields.
	Messages []string
}

ProjectMigrationIssue describes one project whose project.yaml uses the pre-Phase-3-e schema (top-level kits / host_commands / additional_bindings / secret_namespace / capabilities, or task_behaviors.<name>.kits) and must be migrated to the new workspace+kit layout via `boid project migrate`.

type ProjectMissingError added in v0.0.8

type ProjectMissingError struct {
	ProjectID string // registered project ID
	Dir       string // expected project root (where .boid/project.yaml should live)
	Err       error  // underlying os.ReadFile error (preserved for diagnostics / errors.Is)
}

ProjectMissingError is a typed error indicating that a project registered in the DB no longer has a project.yaml on disk (the project directory was removed, the path moved, etc.). It is distinct from *ProjectMigrationError, which signals schema migration is required.

Callers (boid start parent / server wire) extract this via errors.As and auto-prune the stale row from the project DB so the daemon can boot instead of refusing because of a dangling registration. The decision is data-safe: the project.yaml is the source of truth, so a DB row that points at a vanished directory is unambiguously stale.

func (*ProjectMissingError) Error added in v0.0.8

func (e *ProjectMissingError) Error() string

Error matches the legacy `project "<id>": <inner>` shape used by wrapPerProjectLoadErr so existing log output is byte-identical.

func (*ProjectMissingError) Unwrap added in v0.0.8

func (e *ProjectMissingError) Unwrap() error

Unwrap exposes the underlying os.ReadFile error so callers can errors.Is(err, fs.ErrNotExist) without knowing about ProjectMissingError.

type ProjectRepository

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

func NewProjectRepository

func NewProjectRepository(db db.DBTX) *ProjectRepository

func (*ProjectRepository) AssignDefaultWorkspaceToUnlinked added in v0.0.8

func (r *ProjectRepository) AssignDefaultWorkspaceToUnlinked(workspaceID string) (int, error)

AssignDefaultWorkspaceToUnlinked inserts a project_workspaces row pointing at workspaceID for every project that does not yet have one. Returns the number of rows inserted. See the package-level function for the underlying statement.

func (*ProjectRepository) AssignWorkspaceIfExists added in v0.0.13

func (r *ProjectRepository) AssignWorkspaceIfExists(projectID, workspaceID string) error

AssignWorkspaceIfExists atomically checks-then-assigns (MAJOR 3, codex review). See the package-level function's doc comment. r.db must be a *sql.DB — every production wiring path constructs ProjectRepository with srv.db (the daemon's single *sql.DB handle), so this type assertion only fails for a hand-rolled ProjectRepository over a *sql.Tx, which no caller does today.

func (*ProjectRepository) CreateProject

func (r *ProjectRepository) CreateProject(project *Project) error

func (*ProjectRepository) DeleteProject

func (r *ProjectRepository) DeleteProject(id string) error

func (*ProjectRepository) GetProject

func (r *ProjectRepository) GetProject(id string) (*Project, error)

func (*ProjectRepository) GetWorkspaceSummary added in v0.0.13

func (r *ProjectRepository) GetWorkspaceSummary(slug string) (*WorkspaceSummary, error)

GetWorkspaceSummary returns a single workspace's summary (project count + revision). See the package-level function's doc comment for the os.ErrNotExist contract.

func (*ProjectRepository) ListProjectWorkspaceReferences added in v0.0.13

func (r *ProjectRepository) ListProjectWorkspaceReferences() ([]*WorkspaceSummary, error)

ListProjectWorkspaceReferences returns project_workspaces membership directly (see the package-level function's doc comment for why this differs from ListWorkspaces and who still needs it).

func (*ProjectRepository) ListProjects

func (r *ProjectRepository) ListProjects() ([]*Project, error)

func (*ProjectRepository) ListWorkspaces

func (r *ProjectRepository) ListWorkspaces() ([]*WorkspaceSummary, error)

func (*ProjectRepository) SetProjectUpstreamURL added in v0.0.10

func (r *ProjectRepository) SetProjectUpstreamURL(id, upstreamURL string) error

SetProjectUpstreamURL updates a project's captured upstream_url. See the package-level function for the underlying statement.

func (*ProjectRepository) SetProjectWorkspace

func (r *ProjectRepository) SetProjectWorkspace(projectID, workspaceID string) error

func (*ProjectRepository) WorkspaceExists added in v0.0.13

func (r *ProjectRepository) WorkspaceExists(slug string) (bool, error)

WorkspaceExists reports whether slug refers to an existing workspaces table row (MAJOR 5 fix — see WorkspaceExists' doc comment).

type ProjectStore

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

ProjectStore holds project metadata in memory, loaded from project.yaml files.

func NewProjectStore

func NewProjectStore() *ProjectStore

NewProjectStore creates a new store.

func (*ProjectStore) Get

func (s *ProjectStore) Get(id string) (*ProjectMeta, bool)

Get returns the cached meta for a project.

func (*ProjectStore) GetWithWorkspace added in v0.0.8

func (s *ProjectStore) GetWithWorkspace(_ context.Context, projectID string) (*ProjectMeta, error)

GetWithWorkspace returns a ProjectMeta hydrated with workspace-level capabilities, host_commands, additional_bindings, env, and SecretNamespace injection.

Hydration rules:

  • If the project has no linked workspace, returns the cached meta unchanged.
  • If linked: always injects meta.SecretNamespace = workspaceID.
  • On workspace.yaml load success: merges Capabilities, host_commands, additional_bindings, and Env.
  • On os.ErrNotExist (degraded window): logs a warning, returns meta with only SecretNamespace injected (no error).
  • On other errors: returns nil and the error.

The kit mechanism (ws.Kits resolved via a KitResolver and merged in here) was retired in docs/plans/workspace-db-consolidation.md Phase 2.5 PR6; see the NOTE in the body below for what used to live here.

The returned *ProjectMeta is a fresh copy when hydration occurs; callers must not mutate the value returned when workspaceID is empty (it is the cached pointer).

func (*ProjectStore) Load

func (s *ProjectStore) Load(workDir string) (*ProjectMeta, error)

Load reads project.yaml from the work_dir and stores the meta in memory.

func (*ProjectStore) LoadAll

func (s *ProjectStore) LoadAll(projects []*Project) []error

LoadAll reads project.yaml for each registered project and records each project's workspaceID so that GetWithWorkspace can hydrate at call time.

Per-project errors are returned in the original order. When the inner error is a *ProjectMigrationError, the candidate's project ID is stamped onto every Issue in the returned error so downstream callers (e.g. the boid start parent picking issues out via errors.As) can drive auto-migration without parsing strings. Non-migration errors retain the legacy `project "<id>": <wrapped>` form.

func (*ProjectStore) Remove

func (s *ProjectStore) Remove(id string)

Remove deletes a project's meta from the store.

func (*ProjectStore) Set

func (s *ProjectStore) Set(id string, meta *ProjectMeta)

Set stores meta directly.

func (*ProjectStore) SetHostCommands added in v0.0.13

func (s *ProjectStore) SetHostCommands(hostCommands map[string]HostCommandSpec)

SetHostCommands configures the daemon's aggregated host_commands map used to resolve WorkspaceMeta.HostCommands reference names in GetWithWorkspace. Call this before dispatch when workspace host_commands hydration is desired — symmetric with SetWorkspaceStore.

Guarded by s.mu (docs/plans/workspace-db-consolidation.md PR4 Step G, `boid host-commands reload` / POST /api/host_commands/reload): before that endpoint existed, this was only ever called once at startup, strictly before request-serving began, so an unsynchronized field write/read was harmless in practice. A live reload can now race an in-flight GetWithWorkspace call reading the same field, so both sides go through s.mu — see hostCommandSpec below for the read side.

func (*ProjectStore) SetWorkspaceID added in v0.0.8

func (s *ProjectStore) SetWorkspaceID(projectID, workspaceID string)

SetWorkspaceID updates the cached workspace association for a project. Empty workspaceID clears the association. Subsequent GetWithWorkspace calls will hydrate using the new value (or return the cached meta unchanged when cleared).

func (*ProjectStore) SetWorkspaceStore added in v0.0.8

func (s *ProjectStore) SetWorkspaceStore(ws *WorkspaceStore)

SetWorkspaceStore configures the workspace store used by GetWithWorkspace. Call this before LoadAll when workspace hydration is desired.

func (*ProjectStore) WorkspaceStore added in v0.0.8

func (s *ProjectStore) WorkspaceStore() *WorkspaceStore

WorkspaceStore returns the configured workspace store (or nil when none has been wired). Exposed so server-side wiring can hand the same store to dispatcher.Runner for workspace-scoped proxy allowlist resolution at dispatch time, without having to construct a second one.

Note: callers that pass the result into an interface-typed field should guard against the typed-nil trap by checking the concrete return for nil before assigning — see internal/server/wire.go (resolveDispatcherWorkspaceLookup).

type RawPayload

type RawPayload json.RawMessage

func (RawPayload) MarshalJSON

func (p RawPayload) MarshalJSON() ([]byte, error)

func (RawPayload) RawMessage

func (p RawPayload) RawMessage() json.RawMessage

func (*RawPayload) UnmarshalJSON

func (p *RawPayload) UnmarshalJSON(data []byte) error

UnmarshalJSON / MarshalJSON は json.RawMessage と同じ振る舞いを named type に 改めて再実装する。 named type はメソッドを継承しないため、 これが無いと encoding/json は underlying []byte 扱いで base64 文字列を要求してしまい、 JSON object/array 形式の default_payload を弾いてしまう。

func (*RawPayload) UnmarshalYAML

func (p *RawPayload) UnmarshalYAML(node *yaml.Node) error

type RejectRule added in v0.0.9

type RejectRule struct {
	Match  string `yaml:"match" json:"match"`
	Reason string `yaml:"reason" json:"reason"`
}

RejectRule declares a pattern that rejects an invocation with a human-readable reason. Match is a glob over the joined args string with the same semantics as allow/deny patterns (globMatch in internal/sandbox/policy.go). Reason is surfaced to the agent so it can self-correct. This type is vocabulary/transport only for now; enforcement is wired up separately.

type ReplayResult

type ReplayResult struct {
	Result HandlerResult
	// FinalPayload / PayloadDelta carry the same distinction
	// DispatchResult's own fields do — see DispatchResult's doc comment.
	// FinalPayload is a (potentially stale-by-the-time-the-hook-completes)
	// full snapshot; PayloadDelta is only what this single replayed hook
	// actually wrote, safe to apply onto a freshly re-read task row without
	// reverting any out-of-band write the hook itself made mid-flight
	// (Phase 5b PR7 codex review Blocker 1, wiring-seams.md #17).
	FinalPayload json.RawMessage
	PayloadDelta json.RawMessage
	NewStatus    TaskStatus
	FiredEvents  []FiredEvent
}

ReplayResult is the result of a single-hook replay operation.

type Role

type Role string
const (
	RoleHook Role = "hook"
)

type RoutedInstruction

type RoutedInstruction struct {
	Role    string `json:"role"`
	Agent   string `json:"agent"`
	Name    string `json:"name,omitempty"`
	Message string `json:"message"`
	Model   string `json:"model,omitempty"`
}

func CurrentInstructions added in v0.0.13

func CurrentInstructions(task *Task) []RoutedInstruction

CurrentInstructions returns the task's "active" routed instruction — the history's last entry, re-wrapped as a []RoutedInstruction via FilterInstructions(instructions, active.Agent).

NOT a substitute for a specific job's routed instruction (selectInstruction in planner.go / JobSpec.Instruction), and NOT the data source for the Phase 5b `boid task instructions` broker RPC (docs/plans/phase5-shim-and-task-context.md) — see dispatcher.JobContextSnapshot's doc comment and wiring-seams.md #13 for the full story. The two agree only when exactly one agent-kind hook is live for the task's current phase. Evaluator.Evaluate can fire two agent-kind hooks for different agents from the same task in a single round — any agent appearing anywhere in the instruction history matches (extractInstructionAgents), not just the active/last entry — while selectInstruction/FilterInstructions only route the *last* entry, so the other hook's JobSpec.Instruction is nil. This function has no way to tell those two hooks' jobs apart: it always answers with whichever agent happens to be the history's last entry, regardless of which job asked. Safe as a task-row-level "what is this task's routing state right now" projection (no caller in this codebase uses it that way yet), unsafe as an answer to "what should THIS job be doing".

func FilterInstructions

func FilterInstructions(instructions Instructions, agent string) []RoutedInstruction

FilterInstructions returns the active routed instruction for the given agent. Only the most recent entry in the history is considered (older entries are kept for audit but do not drive dispatch). Returns nil when agent is empty or the active entry does not address it. Callers gate on status==executing before routing (see selectInstruction).

type Rule

type Rule struct {
	Action          string // manual transition trigger (mutually exclusive with Condition)
	FromStatus      string // "*" matches any
	ToStatus        string
	Condition       TransitionCondition                   // auto transition trigger (mutually exclusive with Action)
	Manual          bool                                  // true if the action is user-initiated (shown in available_actions)
	ActionPayloadFn func(json.RawMessage) json.RawMessage // optional; generates action.Payload when the rule fires
}

type SecretFinding added in v0.0.8

type SecretFinding struct {
	// Path is the source file path used for attribution.
	Path string
	// Line / Column are 1-based, from the YAML node position.
	Line   int
	Column int
	// Kind is "high-entropy" or "literal-marker".
	Kind string
	// Pattern names the matching marker (only set for literal-marker findings,
	// e.g. "password=").
	Pattern string
	// Snippet is a redacted fingerprint of the offending text suitable for
	// display in error messages: short values are summarised by length only,
	// longer values are reduced to "<first4>...<last4>" so the report never
	// echoes the raw value back to logs.
	Snippet string
}

SecretFinding describes a YAML scalar value that looks like a raw secret (a high-entropy token, or text containing a literal marker such as "password=").

The scanner is intentionally conservative. The goal is to catch obvious leaks of credentials written into kit.yaml / workspace.yaml during `boid kit init` / `boid workspace configure` generation, not to be a comprehensive DLP system. False positives on long opaque identifiers (digest strings, UUIDs without dashes, image references) are accepted as the cost of a simple implementation.

func ScanSecretsBytes added in v0.0.8

func ScanSecretsBytes(path string, data []byte) ([]SecretFinding, error)

ScanSecretsBytes scans an in-memory YAML document.

path is used only for finding attribution (Finding.Path). Pass the expected on-disk path when scanning generated content before persisting it to disk, or any descriptive label for tests.

func ScanSecretsFile added in v0.0.8

func ScanSecretsFile(path string) ([]SecretFinding, error)

ScanSecretsFile reads path as YAML and returns any suspicious-looking scalar values.

Returns nil + nil if the file parses cleanly and contains no findings. Returns nil + non-nil for I/O failures or YAML parse errors so callers can distinguish "could not check" from "checked and clean".

func (SecretFinding) String added in v0.0.8

func (f SecretFinding) String() string

String returns a human-readable single-line description that never includes the raw value.

type StateMachine

type StateMachine struct {
	Name  string
	Rules []Rule
}

func DefaultMachine

func DefaultMachine() *StateMachine

DefaultMachine returns the unified state machine used by all tasks.

func NewMachine

func NewMachine() *StateMachine

NewMachine returns the unified state machine.

Manual transitions:

start  : pending → executing
done   : executing → done     (UI button; agent path goes through done_request + auto)
done   : awaiting → done      (parent confirms child's done_request)
fail   : executing → aborted  (UI button; agent path goes through fail_request + auto)
reopen : done → executing
reopen : aborted → executing  (recover from failure via fix)
ask    : executing → awaiting
answer : awaiting → executing
abort  : * → aborted

Event-driven transitions:

job_failed : * → aborted

Non-transitioning records (created directly by NotifyTask, bypassing ApplyAction):

progress      : * → *   (FYI timeline note)
done_request  : * → *   (agent's `notify --done` intent; consumed by DeriveLifecycle)
fail_request  : * → *   (agent's `notify --fail` intent; consumed by DeriveLifecycle)

Auto transitions (condition-based, evaluated after dispatch). Order matters — first match wins:

executing → aborted when lifecycle.executed && lifecycle.fail
executing → done    when lifecycle.executed && lifecycle.done
executing → done    when lifecycle.executed                     (legacy bare; non-agent hooks)

`lifecycle.{executed,done,fail}` are transient traits injected by the coordinator; they are never persisted to the payload. The state machine treats them as input signals derived from the action history (done_request / fail_request) plus the just-finished hook outcome.

The split between `done_request` (intent recorded immediately) and the auto-advance (state transition after `lifecycle.executed` confirms the runtime exited cleanly) preserves the bash EXIT trap → `boid job done` path. Without this split NotifyTask had to SIGTERM the runtime to apply the state transition synchronously, which raced against the SIGUSR1 graceful-stop path and left jobs marked failed.

Hook failures surface as job_failed via the dispatcher path, which routes the task to aborted.

func (*StateMachine) Advance

func (sm *StateMachine) Advance(task *Task) (*Task, bool)

Advance evaluates condition-based rules for the task's current status and payload. Returns the transitioned task and true if a condition was met, or (nil, false) otherwise. Use AdvanceFull when the action payload is also needed.

func (*StateMachine) AdvanceFull

func (sm *StateMachine) AdvanceFull(task *Task) *AdvanceOutcome

AdvanceFull evaluates condition-based rules for the task's current status and payload. Returns an AdvanceOutcome (including optional action payload) if a condition was met, or nil otherwise.

func (*StateMachine) Apply

func (sm *StateMachine) Apply(task *Task, action *Action) (*Task, error)

Apply finds an action-based rule matching the action type and current status. Condition-based rules are ignored by Apply. When a matching rule has an empty ToStatus the task status is left unchanged (non-transitioning action, e.g. "progress").

func (*StateMachine) AvailableActions

func (sm *StateMachine) AvailableActions(status TaskStatus) []string

AvailableActions returns the list of manual actions that can be applied to a task in the given status. Condition-based (automatic) rules and non-manual rules are excluded. Terminal statuses (done, aborted) return an empty list.

type Task

type Task struct {
	ID           string          `json:"id"`
	ProjectID    string          `json:"project_id"`
	RemoteID     string          `json:"remote_id,omitempty"`
	Title        string          `json:"title"`
	Description  string          `json:"description,omitempty"`
	Status       TaskStatus      `json:"status"`
	Behavior     string          `json:"behavior"`
	Traits       []string        `json:"traits,omitempty"`
	Readonly     bool            `json:"readonly,omitempty"`
	BranchPrefix string          `json:"branch_prefix,omitempty"`
	BaseBranch   string          `json:"base_branch,omitempty"`
	Payload      json.RawMessage `json:"payload"`
	Instructions Instructions    `json:"instructions,omitempty"`
	AutoStart    bool            `json:"auto_start,omitempty"`
	Ref          string          `json:"ref,omitempty"`
	ParentID     string          `json:"parent_id,omitempty"`
	CreatedAt    time.Time       `json:"created_at"`
	UpdatedAt    time.Time       `json:"updated_at"`
	// 以下はDBに保存しない派生フィールド(list/get クエリ時にサブクエリで集計)
	TotalChildCount   int `json:"total_child_count,omitempty"`
	DoneChildCount    int `json:"done_child_count,omitempty"`
	AbortedChildCount int `json:"aborted_child_count,omitempty"`
	OpenChildCount    int `json:"open_child_count,omitempty"`
	// Blocked は表示用フィールド(DBには保存しない)
	Blocked bool `json:"blocked,omitempty"`
}

func FindTaskByRef

func FindTaskByRef(dbtx db.DBTX, ref, parentID string) (*Task, error)

FindTaskByRef returns the task matching the given ref within the given parent scope, or nil if no matching task is found. If ref is a UUID, the task is looked up by id directly (backward compatibility).

func FindTaskByRemote

func FindTaskByRemote(dbtx db.DBTX, remoteID string) (*Task, error)

FindTaskByRemote returns the most recently created task (by created_at DESC, id DESC) matching the given remote_id, or nil if no matching task is found.

func GetTask

func GetTask(dbtx db.DBTX, id string) (*Task, error)

func ListChildren added in v0.0.6

func ListChildren(dbtx db.DBTX, parentID string) ([]*Task, error)

ListChildren returns all direct children of the given parent task, ordered by created_at ASC (oldest first). Returns an empty slice if the task has no children — never nil. parentID must be non-empty; passing "" returns an empty result (root tasks have no parent record, so they can't be queried as children either).

func ListTasks

func ListTasks(dbtx db.DBTX, filter TaskFilter) ([]*Task, error)

type TaskBehavior

type TaskBehavior struct {
	// Readonly controls whether the sandbox working directory is mounted read-only
	// for tasks using this behavior. When nil (unset), the daemon applies a
	// fail-safe default: readonly=true for all behaviors except the canonical
	// "executor" (which retains readonly=false during the compat period).
	// Set explicitly to override: readonly: false in project.yaml.
	Readonly           *bool        `yaml:"readonly,omitempty" json:"readonly,omitempty"`
	Traits             []string     `yaml:"traits" json:"traits"`
	DefaultInstruction *Instruction `yaml:"default_instruction,omitempty" json:"default_instruction,omitempty"`

	// Hooks is parsed from project.yaml task_behaviors.<name>.hooks at load
	// time. Env, HostCommands, and AdditionalBindings are runtime-overlay
	// fields populated by ReadProjectMetaWithKits after merging kit data and
	// project-level overlays. These are not serialized to YAML.
	Hooks              []Hook            `yaml:"hooks,omitempty" json:"-"`
	Env                map[string]string `yaml:"-" json:"-"`
	HostCommands       HostCommands      `yaml:"-" json:"-"`
	AdditionalBindings []BindMount       `yaml:"-" json:"-"`
}

func LookupBehaviorWithAlias

func LookupBehaviorWithAlias(meta *ProjectMeta, name string) (TaskBehavior, string, bool)

LookupBehaviorWithAlias finds a TaskBehavior in meta.TaskBehaviors by name, being tolerant of the plan / dev → supervisor / executor rename. Lookup is tried in this order:

  1. exact match against the requested name
  2. if the request is a legacy alias, try the canonical name
  3. if the request is a canonical name, try the legacy alias (handles unnormalized in-memory ProjectMeta values that may exist in tests or transitional code paths)

When (2) or (3) hits, a deprecation warning is logged. The returned key is the map key that actually matched; callers may use it for further logging or store the canonical form on the task.

type TaskFilter

type TaskFilter struct {
	Status      string
	ProjectID   string
	Behavior    string
	WorkspaceID string
	Title       string
	ParentID    *string
}

type TaskGCStore

type TaskGCStore struct {

	// RuntimeReaper, when set, is called with each runtime directory path
	// before os.RemoveAll removes it. Use this to Reap docker resources that
	// may still be alive in the upstream daemon (safety net for jobs whose
	// cleanupSandboxAfterWait did not complete, e.g. daemon restart).
	RuntimeReaper func(runtimeDir string) error
	// contains filtered or unexported fields
}

TaskGCStore handles GC of tasks and their related data.

func NewTaskGCStore

func NewTaskGCStore(conn *sql.DB) *TaskGCStore

func (*TaskGCStore) GC

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

func (*TaskGCStore) WithAttachmentsRoot added in v0.0.7

func (s *TaskGCStore) WithAttachmentsRoot(dir string) *TaskGCStore

WithAttachmentsRoot enables disk-level cleanup of the per-task attachments directory tree rooted at `<dir>/tasks/<id>/attachments`. dir is the data-home (matches dataHomeFor in wire.go). Empty disables the cleanup.

func (*TaskGCStore) WithRuntimeReaper added in v0.0.2

func (s *TaskGCStore) WithRuntimeReaper(fn func(runtimeDir string) error) *TaskGCStore

WithRuntimeReaper sets a callback that is invoked with each runtime directory path before it is deleted. This allows the caller to Reap docker resources created by sandbox jobs (safety net when cleanupSandboxAfterWait didn't run, e.g. after a daemon restart).

func (*TaskGCStore) WithRuntimesDir added in v0.0.11

func (s *TaskGCStore) WithRuntimesDir(dir string) *TaskGCStore

WithRuntimesDir enables disk-level cleanup of per-sandbox runtime directories (`<dir>/<runtime_id>` and the git-gateway clone workspace dir `<dir>/<job_id>/workspace`) for GC target jobs. Empty disables runtime cleanup.

func (*TaskGCStore) WithSandboxTmpDir

func (s *TaskGCStore) WithSandboxTmpDir(dir string) *TaskGCStore

WithSandboxTmpDir enables safety-net cleanup of leaked /tmp/boid-* sandbox artifacts during GC. Pass the directory to scan (typically "/tmp"); empty string disables this cleanup.

type TaskLookup

type TaskLookup interface {
	GetTask(id string) (*Task, error)
}

type TaskRepository

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

func NewTaskRepository

func NewTaskRepository(db db.DBTX) *TaskRepository

func (*TaskRepository) CreateAction

func (r *TaskRepository) CreateAction(action *Action) error

func (*TaskRepository) CreateTask

func (r *TaskRepository) CreateTask(task *Task) error

func (*TaskRepository) DeleteTask

func (r *TaskRepository) DeleteTask(id string) error

func (*TaskRepository) FindTaskByRef

func (r *TaskRepository) FindTaskByRef(ref, parentID string) (*Task, error)

func (*TaskRepository) FindTaskByRemote

func (r *TaskRepository) FindTaskByRemote(remoteID string) (*Task, error)

func (*TaskRepository) GetTask

func (r *TaskRepository) GetTask(id string) (*Task, error)

func (*TaskRepository) ListActionsByTask

func (r *TaskRepository) ListActionsByTask(taskID string) ([]*Action, error)

func (*TaskRepository) ListChildren added in v0.0.6

func (r *TaskRepository) ListChildren(parentID string) ([]*Task, error)

func (*TaskRepository) ListTasks

func (r *TaskRepository) ListTasks(filter TaskFilter) ([]*Task, error)

func (*TaskRepository) UpdateTask

func (r *TaskRepository) UpdateTask(task *Task) error

type TaskSnapshot

type TaskSnapshot struct {
	ID          string `json:"id"`
	Title       string `json:"title"`
	Status      string `json:"status"`
	Behavior    string `json:"behavior"`
	Description string `json:"description,omitempty"`
	Readonly    bool   `json:"readonly"`
}

TaskSnapshot is the business metadata `boid task current` returns over the broker RPC (Phase 5b PR1, docs/plans/phase5-shim-and-task-context.md). Through the Phase 5b PR6 cutover it also materialized at the now-deleted $HOME/.boid/context/task.yaml; JSON tags stayed lowercase to match that file's key casing (marshalTaskYAML, also deleted) and the snake_case convention every other boid RPC response uses.

Readonly (Phase 5b PR4, docs/plans/phase5-shim-and-task-context.md「PR 分割案 > 5b」4) is the mode-determination source the boid-task skill reads via `boid task current --field readonly`.

func SnapshotTask added in v0.0.13

func SnapshotTask(task *Task) *TaskSnapshot

SnapshotTask projects a Task down to the business-metadata subset TaskSnapshot carries (see its doc comment) — through the Phase 5b PR6 cutover, also historically materialized at the now-deleted $HOME/.boid/context/task.yaml. Exported so internal/api's Phase 5b PR1 `boid task current` RPC (docs/plans/phase5-shim-and-task-context.md) reuses the exact same projection instead of re-deriving it.

Readonly is populated from task.Readonly directly, the same field IsReadonly reads — there is no second source of truth to drift against.

type TaskStatus

type TaskStatus string
const (
	TaskStatusPending   TaskStatus = "pending"
	TaskStatusExecuting TaskStatus = "executing"
	TaskStatusAwaiting  TaskStatus = "awaiting"
	TaskStatusDone      TaskStatus = "done"
	TaskStatusAborted   TaskStatus = "aborted"
)

type TraitType

type TraitType string
const (
	TraitArtifact     TraitType = "artifact"
	TraitVerification TraitType = "verification"
	TraitAwaiting     TraitType = "awaiting"
)

func ActiveTraitTypes

func ActiveTraitTypes(raw json.RawMessage) ([]TraitType, error)

func (TraitType) Base

func (t TraitType) Base() TraitType

Base returns the trait name without the optional "?" suffix.

func (TraitType) IsOptional

func (t TraitType) IsOptional() bool

IsOptional reports whether the trait is declared with a trailing "?".

type TransitionCondition

type TransitionCondition func(payload json.RawMessage) bool

TransitionCondition evaluates whether a condition-based transition should fire.

type Visibility

type Visibility struct {
	// ProjectDir is the host path to the project working directory.
	// Empty means the project filesystem is not visible.
	ProjectDir string

	// ProjectName is project.yaml's `meta.name` (kebab-case by convention,
	// not enforced), threaded through from the workspace-hydrated
	// ProjectMeta at JobSpec-build time (PlanHook / BuildSessionJobSpec).
	// dispatcher uses it — falling back to filepath.Base(ProjectDir) when
	// empty — to name the sandbox-internal clone directory under the
	// /workspace parent dir (workspace 親化リファクタリング, nose
	// 2026-07-13 decision): every project previously shared the exact same
	// sandbox cwd ("/workspace"), which collided Claude Code's
	// `~/.claude/projects/-workspace/` session-log slug across every boid
	// project. Empty is a legitimate value (a project with no `name:` in
	// project.yaml, or a dispatch path — e.g. gate/hook jobs with no
	// project visible at all — that never resolves one); dispatcher's
	// fallback degrades gracefully rather than erroring.
	ProjectName string

	// AdditionalBindings lists extra host bind-mounts (e.g. kit-provided CLIs
	// like the claude binary) that must be visible inside the sandbox.
	AdditionalBindings []BindMount

	// Writable permits writes to ProjectDir. When ProjectDir is empty, this
	// field has no effect.
	Writable bool

	// DockerEnabled, when true, indicates capabilities.docker was declared in
	// project.yaml. Dispatcher uses this to start a per-sandbox docker proxy.
	DockerEnabled bool

	// Clone declares the sandbox-internal-clone branch state for real dispatch
	// (docs/plans/git-gateway-cutover.md PR5 「5. branch 宣言の JobSpec 化」・
	// PR6 cutover). nil leaves dispatch unaffected (test-only JobSpecs that
	// don't exercise clone-mode). When non-nil, dispatcher does not resolve
	// the branch itself against a host repo — it carries this declaration
	// through to the sandbox so the runner resolves it (rev-parse /
	// merge-base / checkout -B) after cloning inside the sandbox.
	Clone *CloneDeclaration
}

Visibility captures which host paths the sandbox sees and whether they are writable. orchestrator sets this once per JobSpec; dispatcher turns it into mount entries with no further role-aware logic.

type WorkspaceMeta added in v0.0.8

type WorkspaceMeta struct {
	// Env holds environment variable overrides applied to every sandbox
	// launched under this workspace. Values here take precedence over
	// kit-supplied env but are overridden by project.yaml env.
	Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`

	// Capabilities declares optional sandbox capability flags for this
	// workspace. Uses the same Capabilities type as ProjectMeta.
	Capabilities Capabilities `yaml:"capabilities,omitempty" json:"capabilities,omitempty"`

	// AllowedDomains is the workspace-scoped HTTP(S) proxy egress allowlist.
	// Domains listed here are ADDED to the daemon-wide allowlist
	// (config.yaml sandbox.allowed_domains); the workspace cannot remove
	// entries from the global floor — that floor exists to keep
	// pypi/github/etc reachable for tool installation.
	//
	// Same matching rules as the global list (see sandbox.Proxy):
	//   - "registry-1.docker.io"  exact match
	//   - ".cosmos.azure.com"     suffix match (matches "<sub>.cosmos.azure.com")
	AllowedDomains []string `yaml:"allowed_domains,omitempty" json:"allowed_domains,omitempty"`

	// ExtraRepos is the workspace-scoped, read-only allowlist of additional
	// git repositories outside this workspace's own projects that jobs may
	// fetch (never push) via the git gateway — e.g. a private go module
	// dependency (docs/plans/git-gateway-cutover.md 「workspace peer」節:
	// 「workspace 設定に read-only の追加許可 repo」). Entries are upstream
	// URLs in any form dispatcher.NormalizeOriginURL accepts (HTTPS or SSH);
	// dispatcher resolves each into a gitgateway.RepoKey at dispatch time and
	// grants it PermFetch alongside this workspace's own projects and peers.
	// Same additive, floor-cannot-shrink relationship to the (currently
	// nonexistent) global floor as AllowedDomains — there is no write grant
	// here, fetch-only by construction.
	ExtraRepos []string `yaml:"extra_repos,omitempty" json:"extra_repos,omitempty"`

	// HostCommands is the reference-name list into the aggregated
	// host_commands config assembled at daemon startup (see
	// host_commands_config.go's LoadHostCommandsFromKits /
	// WriteHostCommandsConfig and docs/plans/workspace-db-consolidation.md
	// 「host_commands 実定義の集約先」). Unlike Kits, this field carries only
	// names — no path/allow/env/reject definitions travel with the
	// workspace itself; those live in the daemon-wide
	// ~/.config/boid/host_commands.yaml and are resolved by name at
	// GetWithWorkspace hydration time (ProjectStore.hostCommands).
	// Populated by MigrateWorkspaceYAMLToDB from each workspace's former
	// Kits list, unioned with any host_commands the workspace already had.
	HostCommands []string `yaml:"host_commands,omitempty" json:"host_commands,omitempty"`

	// ContainerImage is a nullable field reserved for the Phase 6 container
	// backend (decision 7, docs/plans/workspace-db-consolidation.md).
	// Dispatch ignores it entirely until then — the field only exists so
	// the schema migration for it does not need to be revisited later.
	ContainerImage string `yaml:"container_image,omitempty" json:"container_image,omitempty"`
}

WorkspaceMeta holds the machine-local workspace configuration that is stored in ~/.config/boid/workspaces/<slug>.yaml.

A workspace defines host_command references, environment variable overrides, and optional capability flags for sandboxes. Fields that are project-specific (secret_namespace, host_commands, additional_bindings, name, description, version) are intentionally absent; they remain in project.yaml.

A Kits field used to live here (an ordered list of kit slugs, resolved and merged in at hydration time). The kit mechanism itself was retired in docs/plans/workspace-db-consolidation.md Phase 2.5 PR6, and this field was removed outright in PR7 (decision 12: no fallback for the old field kept). The `workspaces` DB table never had a column for it (WorkspaceRepository. Load/Save never read/wrote it), so removing the struct field is a pure cleanup with no persisted-data migration implied. Two client-side call sites still resolve a legacy `kits:` reference list against MaterializeWorkspaceKitsForPersist, but source it from outside this type now (cmd/workspace.go's ensureWorkspaceExistsForAssign extracts it from the raw shadow yaml; cmd/project_migrate.go folds its own auto-generated legacy kit's fields in directly, without any kit list at all) — see those call sites for why.

func DecodeWorkspaceCreateStrict added in v0.0.13

func DecodeWorkspaceCreateStrict(data []byte) (slug string, meta *WorkspaceMeta, err error)

DecodeWorkspaceCreateStrict strictly decodes a POST /api/workspaces create body into its target slug and WorkspaceMeta. An empty body decodes to an empty slug (the caller is expected to reject that with "slug is required") and a zero-value WorkspaceMeta, mirroring DecodeWorkspaceMetaStrict's empty-body handling. A second "---"-delimited document is rejected too (MINOR 2, codex review round 2) — see RejectTrailingYAMLDocument.

func DecodeWorkspaceMetaStrict added in v0.0.13

func DecodeWorkspaceMetaStrict(data []byte) (*WorkspaceMeta, error)

DecodeWorkspaceMetaStrict strictly decodes a workspace meta YAML document (as used by PUT /api/workspaces/{slug} — the whole-document replace body). An empty/whitespace-only body decodes to a zero-value WorkspaceMeta rather than erroring on io.EOF: a body-less create/edit is a legitimate way to declare an empty workspace. A typo'd or unknown field (top-level or nested inside additional_bindings/capabilities) is rejected with an error naming the offending field. A second "---"-delimited document is rejected too (MINOR 2, codex review round 2) — see RejectTrailingYAMLDocument.

type WorkspaceRepository added in v0.0.13

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

WorkspaceRepository provides DB-backed CRUD for WorkspaceMeta against the `workspaces` table (internal/db/migrate/migrations/0030_add_workspaces_table.sql). It is the authority WorkspaceStore delegates to once MigrateWorkspaceYAMLToDB has cut over (docs/plans/workspace-db-consolidation.md PR3): the yaml files under DefaultWorkspaceDir() become a read-only shadow kept for rollback/export, and this repository becomes the read/write path.

func NewWorkspaceRepository added in v0.0.13

func NewWorkspaceRepository(conn *sql.DB) *WorkspaceRepository

NewWorkspaceRepository returns a WorkspaceRepository backed by conn.

func (*WorkspaceRepository) Create added in v0.0.13

func (r *WorkspaceRepository) Create(slug string, meta *WorkspaceMeta) error

Create inserts a brand-new workspace row at slug. Unlike Save, this is insert-only: a slug that already has a row is rejected with an error wrapping os.ErrExist (docs/plans/workspace-db-consolidation.md Step A — the API layer maps this to HTTP 409 for POST /api/workspaces) rather than silently overwriting it. The plain INSERT (no ON CONFLICT clause) makes SQLite itself the source of truth for the conflict, so a concurrent creator racing this call is still caught by the UNIQUE constraint on workspaces.slug rather than a separate (racy) existence check.

func (*WorkspaceRepository) EnsureDefault added in v0.0.13

func (r *WorkspaceRepository) EnsureDefault() error

EnsureDefault inserts an empty row for DefaultWorkspaceSlug if none exists yet. It is safe to call repeatedly: an existing default workspace (with or without user edits) is left untouched.

func (*WorkspaceRepository) List added in v0.0.13

func (r *WorkspaceRepository) List() ([]string, error)

List returns every configured workspace slug, sorted alphabetically.

func (*WorkspaceRepository) Load added in v0.0.13

func (r *WorkspaceRepository) Load(slug string) (*WorkspaceMeta, error)

Load reads and decodes the WorkspaceMeta for the given slug. Returns an error wrapping os.ErrNotExist when no row exists — matching WorkspaceStore.Load's contract so callers do not need to branch on which backing store is in use.

func (*WorkspaceRepository) LoadWithRevision added in v0.0.13

func (r *WorkspaceRepository) LoadWithRevision(slug string) (*WorkspaceMeta, string, error)

LoadWithRevision reads meta and its revision (updated_at, formatted the same way GetWorkspaceSummary/ListWorkspaces do) from a single row — docs/plans/workspace-db-consolidation.md MAJOR 1 (codex review): GET /api/workspaces/{slug} previously read meta (this method's SELECT) and revision (a separate GetWorkspaceSummary query) as two round trips, which could straddle a concurrent PUT and return a meta/revision pair that never coexisted in the DB. Returns an error wrapping os.ErrNotExist when no row exists for slug, matching Load's contract.

func (*WorkspaceRepository) Remove added in v0.0.13

func (r *WorkspaceRepository) Remove(slug string) error

Remove deletes the workspace row for slug. The reserved DefaultWorkspaceSlug cannot be removed (docs/plans/workspace-db-consolidation.md 「default workspace の実装詳細」). Any project currently assigned to slug is re-pointed at DefaultWorkspaceSlug in the same transaction as the delete, so a project never ends up referencing a workspace that no longer exists. Returns an error wrapping os.ErrNotExist when no row exists for slug.

func (*WorkspaceRepository) Save added in v0.0.13

func (r *WorkspaceRepository) Save(slug string, meta *WorkspaceMeta) error

Save upserts meta at slug: INSERT if the slug has no row yet, or overwrite every column in place if it does. updated_at is always bumped to the current time.

func (*WorkspaceRepository) UpdateIfRevisionMatches added in v0.0.13

func (r *WorkspaceRepository) UpdateIfRevisionMatches(slug string, expectedRevision string, meta *WorkspaceMeta) (newRevision string, matched bool, err error)

UpdateIfRevisionMatches performs a compare-and-swap update: meta at slug is written only if the row's current revision (updated_at) equals expectedRevision, atomically with the check, via a single UPDATE statement (docs/plans/workspace-db-consolidation.md MAJOR 1, codex review). This closes the PUT race the previous "read revision, then Save unconditionally" two-step had: two concurrent PUTs against the same starting ETag could otherwise both pass their (separate-query) If-Match check and both Save, silently losing one writer's update; likewise a DELETE landing between a GET and a subsequent PUT could no longer be resurrected by an upsert-based Save.

matched=false covers three cases the caller cannot tell apart from this return value alone: slug has no row at all, slug exists but its current revision differs from expectedRevision, or expectedRevision is not even a well-formed revision string (e.g. a client-supplied If-Match that was never a real ETag this server issued) — the last case is deliberately folded into "no match" rather than a hard error, since a malformed value trivially can never equal the real (always well-formed) current revision; this keeps the HTTP mapping a plain 412, not a spurious 500, for a garbage If-Match header. ProjectAppService.UpdateWorkspace distinguishes "no row" from the other two with a follow-up existence check (404 vs 412) — see its doc comment.

On success, returns the freshly bumped revision (formatted the same way as LoadWithRevision/GetWorkspaceSummary) so the caller can hand it back to the client as the new ETag without a second read.

type WorkspaceStore added in v0.0.8

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

WorkspaceStore provides persistence for WorkspaceMeta values.

Two backing modes (docs/plans/workspace-db-consolidation.md PR3 cutover):

  • repo == nil: legacy yaml mode. Each workspace is read/written as a YAML file at <dir>/<slug>.yaml. This is the pre-cutover behavior, kept as-is for callers that never wire a repository — e.g. the CLI (cmd/workspace.go and friends still read/write the yaml files directly; PR4 switches them to the API) and MigrateWorkspaceYAMLToDB's own preflight, which needs to read the legacy yaml as the migration's source of truth.
  • repo != nil (set via SetRepository / NewWorkspaceStoreWithRepo): DB mode. Every method delegates to repo instead of touching dir. dir is retained on the struct only as the (now-shadow) yaml location; PR3 does not delete or read it once a repository is wired.

Every method's signature is unchanged from the yaml-only era so existing callers (cmd/workspace.go, cmd/project_migrate.go, cmd/kit.go, cmd/kit_cleanup.go, dispatcher.WorkspaceLookup) need zero code changes — only daemon wiring (internal/server/wire.go) opts into DB mode by calling SetRepository.

func NewWorkspaceStore added in v0.0.8

func NewWorkspaceStore(dir string) *WorkspaceStore

NewWorkspaceStore returns a yaml-mode WorkspaceStore backed by dir. If dir is empty, DefaultWorkspaceDir is called to determine the directory. The directory is not created eagerly; it is created on the first Save call. Call SetRepository afterward (or use NewWorkspaceStoreWithRepo) to switch this store into DB mode.

func NewWorkspaceStoreWithRepo added in v0.0.13

func NewWorkspaceStoreWithRepo(dir string, repo *WorkspaceRepository) *WorkspaceStore

NewWorkspaceStoreWithRepo returns a DB-mode WorkspaceStore: dir is kept only as the shadow yaml location, and every method delegates to repo.

func (*WorkspaceStore) Create added in v0.0.13

func (s *WorkspaceStore) Create(slug string, meta *WorkspaceMeta) error

Create writes a brand-new workspace at slug, insert-only: a slug that already exists (as a DB row in DB mode, or a yaml file in yaml mode) is rejected with an error wrapping os.ErrExist rather than silently overwritten (docs/plans/workspace-db-consolidation.md Step A — the API's POST /api/workspaces create endpoint relies on this to return HTTP 409).

func (*WorkspaceStore) EnsureDefault added in v0.0.8

func (s *WorkspaceStore) EnsureDefault() error

EnsureDefault writes an empty WorkspaceMeta to the DefaultWorkspaceSlug path if no file exists yet. It is safe to call repeatedly and a no-op once the file exists (the existing content — including user edits — is left untouched). Returns nil when the workspace dir cannot be determined yet (DefaultWorkspaceDir failure): callers are expected to surface that via the first Load/Save attempt rather than at boot.

func (*WorkspaceStore) List added in v0.0.8

func (s *WorkspaceStore) List() ([]string, error)

List returns the slug of every workspace stored in the directory, sorted alphabetically. If the directory does not exist, an empty slice and nil error are returned (degraded window).

func (*WorkspaceStore) Load added in v0.0.8

func (s *WorkspaceStore) Load(slug string) (*WorkspaceMeta, error)

Load reads and parses the WorkspaceMeta for the given slug. Returns an error wrapping os.ErrNotExist when the file does not exist.

In DB mode (repo != nil) this delegates straight to repo.Load and never falls back to the yaml file at <dir>/<slug>.yaml. PR3's cutover had a transitional yaml fallback here (a not-found DB row fell back to reading the legacy yaml file) because the daemon had no post-cutover create path yet — a workspace yaml dropped into the workspaces dir *after* daemon startup (missed by MigrateWorkspaceYAMLToDB's one-time migration) would otherwise silently degrade (capabilities/kits/env not injected). PR4 removes that fallback now that POST /api/workspaces (and cmd/workspace.go's `boid workspace assign` auto-create) gives every caller a real way to introduce a DB row outside the migration path — the yaml files under DefaultWorkspaceDir() are now purely a rollback/export shadow (decision 16), never read by DB-mode Load again.

func (*WorkspaceStore) LoadWithRevision added in v0.0.13

func (s *WorkspaceStore) LoadWithRevision(slug string) (*WorkspaceMeta, string, error)

LoadWithRevision reads meta and its revision from a single atomic snapshot in DB mode (docs/plans/workspace-db-consolidation.md MAJOR 1, codex review) — see WorkspaceRepository.LoadWithRevision's doc comment for why this matters. Yaml mode (repo == nil) has no revision concept, so it falls back to plain Load with an empty revision string.

func (*WorkspaceStore) Remove added in v0.0.8

func (s *WorkspaceStore) Remove(slug string) error

Remove deletes the YAML file for the given slug. Returns an error wrapping os.ErrNotExist when the file does not exist. The reserved DefaultWorkspaceSlug cannot be removed. It does not check whether any project references this workspace.

func (*WorkspaceStore) Save added in v0.0.8

func (s *WorkspaceStore) Save(slug string, meta *WorkspaceMeta) error

Save atomically writes meta as YAML to <dir>/<slug>.yaml. The directory is created with mode 0755 if it does not exist. The write is atomic: the data is first written to a temporary file in the same directory and then renamed into place.

func (*WorkspaceStore) SetRepository added in v0.0.13

func (s *WorkspaceStore) SetRepository(repo *WorkspaceRepository)

SetRepository wires repo as this store's DB backing (docs/plans/workspace-db-consolidation.md PR3 cutover). Once set, every method routes through repo instead of the yaml directory.

func (*WorkspaceStore) UpdateIfRevisionMatches added in v0.0.13

func (s *WorkspaceStore) UpdateIfRevisionMatches(slug string, expectedRevision string, meta *WorkspaceMeta) (newRevision string, matched bool, err error)

UpdateIfRevisionMatches performs the CAS update described on WorkspaceRepository.UpdateIfRevisionMatches in DB mode. Yaml mode (repo == nil) has no revision concept to enforce — it always reports matched=true and performs an unconditional Save, i.e. the same non-CAS overwrite semantics this mode's plain Save has always had.

type WorkspaceSummary

type WorkspaceSummary struct {
	ID           string `json:"id"`
	ProjectCount int    `json:"project_count"`
	// Revision is an opaque ETag-like token derived from the workspaces row's
	// updated_at column (RFC3339), used by the PUT /api/workspaces/{slug}
	// If-Match optimistic-concurrency check (docs/plans/
	// workspace-db-consolidation.md decision 17). Empty when the summary was
	// built from a project_workspaces reference with no corresponding
	// workspaces row (should not happen once PR4's ListWorkspaces query is
	// workspaces-table-based, but callers should not assume non-empty).
	Revision string `json:"revision,omitempty"`
}

func GetWorkspaceSummary added in v0.0.13

func GetWorkspaceSummary(dbtx db.DBTX, slug string) (*WorkspaceSummary, error)

GetWorkspaceSummary returns a single workspace's summary (project count + revision), or an error wrapping os.ErrNotExist when slug has no corresponding workspaces row. Used by the workspace API handlers (docs/plans/workspace-db-consolidation.md PR4) to build the create/show/update response and to read the current revision for the PUT If-Match check.

func ListProjectWorkspaceReferences added in v0.0.13

func ListProjectWorkspaceReferences(dbtx db.DBTX) ([]*WorkspaceSummary, error)

ListProjectWorkspaceReferences returns the distinct workspace_id values referenced by any project_workspaces row, with the count of projects referencing each — the pre-PR4 query ListWorkspaces used to run. Unlike ListWorkspaces (workspaces-table-based as of Step B, docs/plans/ workspace-db-consolidation.md), this reflects project_workspaces membership directly and surfaces a reference to a slug that has no corresponding workspaces row at all. That distinction matters exactly once: workspace_migration.go's preflight runs *before* the workspace_db_consolidation migration has written anything to the workspaces table, and needs to detect a project referencing a slug with no legacy yaml file backing it (a broken reference) — which the workspaces-table-based ListWorkspaces would silently miss, since a nonexistent workspaces row simply never appears in a LEFT JOIN FROM workspaces. No other caller should need this function once the migration is long past (dispatch and the API both go through ListWorkspaces).

func ListWorkspaces

func ListWorkspaces(dbtx db.DBTX) ([]*WorkspaceSummary, error)

ListWorkspaces returns every workspace known to the workspaces table, each annotated with its assigned project count and a Revision token (docs/plans/workspace-db-consolidation.md Step B). The query is workspaces-table-based with project_workspaces LEFT JOINed in, so a workspace with zero assigned projects still appears (ProjectCount=0) — unlike the pre-PR4 query, which GROUP-BY'd project_workspaces directly and could only ever surface a slug that at least one project referenced.

Directories

Path Synopsis
Package refname generates random "adjective_noun" style names for task refs.
Package refname generates random "adjective_noun" style names for task refs.

Jump to

Keyboard shortcuts

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