native

package
v1.9.2 Latest Latest
Warning

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

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

Documentation

Overview

Package native implements iterion's first-class issue/kanban tracker. Issues live as one JSON file per issue under <root>/issues/, a board config sits at <root>/board.json, and every mutation appends a monotonically-sequenced record to <root>/events.jsonl. All writes are serialized through a single mutex; reads scan the filesystem.

Index

Constants

View Source
const (
	StateInbox   = "inbox"
	StateBacklog = "backlog"
	StateReady   = "ready"
	// StateWaitingDeps holds a ticket whose hard blockers are not yet
	// StateDone. Non-eligible and non-terminal: the launch loop and the
	// dispatcher skip it, and it does not satisfy anyone else's blockers
	// (unlike StateBlocked, which is terminal "won't do").
	StateWaitingDeps = "waiting_deps"
	StateInProgress  = "in_progress"
	// StateAwaitingInput holds a dispatched card whose run paused waiting for
	// a human answer (paused_waiting_human). Non-eligible (the dispatcher
	// never re-picks it) and non-terminal (the run resumes on answer). The
	// column-level expression of the per-card AwaitingInput badge.
	StateAwaitingInput = "awaiting_input"
	StateReview        = "review"
	StateDone          = "done"
	// StateBlocked is terminal "won't do" / abandoned — not a temporary
	// hold for open deps (use StateWaitingDeps). A ticket in blocked does
	// NOT satisfy hard blockers of dependents (see BlockerSatisfied).
	StateBlocked = "blocked"
)

Default state names emitted by DefaultBoard. Callers that customise the board can ignore these; tests and skills referring to the shipped defaults should use the constants so renames stay compile-checked.

View Source
const (
	// LabelTriageAuto is the one-shot trigger label: a trusted author's fresh
	// card carries it, and the triage bot's consume_labels subscription
	// strips it as it fires. Re-adding it re-arms the triage.
	LabelTriageAuto = "triage:auto"
	// LabelNeedsApproval parks an untrusted-author card: no bot run of any
	// kind until an operator swaps it for LabelTriageAuto (approve gesture)
	// or routes the card manually.
	LabelNeedsApproval = "needs:approval"
)

Board-local trust labels stamped by the forge→board ingest gate. They are namespaced so the forge sync's label refresh preserves them (board-local namespaces survive a sweep; plain forge labels are mirrored verbatim).

View Source
const (
	// BotArgInputPath is the primary immutable request file path (relative to
	// the workspace). Upsert key with Bot: (bot, input_path).
	BotArgInputPath = "input_path"
	// BotArgRevisionID / BotArgRequestHash — immutability / cache identity.
	BotArgRevisionID  = "revision_id"
	BotArgRequestHash = "request_hash"
	// Correlation ids.
	BotArgAssetID   = "asset_id"
	BotArgFeatureID = "feature_id"
	BotArgFamilyID  = "family_id"
	// BotArgPipelineKind — mesh | humanoid | feature | custom (filter surface).
	BotArgPipelineKind = "pipeline_kind"
	// Serialized artefact / dependency lists (JSON strings in bot_args).
	BotArgProduces = "produces"
	BotArgConsumes = "consumes"
	BotArgDocRefs  = "doc_refs"
	// BotArgAutoReady — when truthy, waiting_deps → ready on unblock (else backlog).
	BotArgAutoReady = "auto_ready"
	// BotArgRequireBlockerLabels — comma-separated labels every hard blocker
	// must also carry once done (e.g. "accepted"). Empty = state-only gate.
	BotArgRequireBlockerLabels = "require_blocker_labels"
	// BotArgSpawnedFrom — planner ticket id that published this card. Kept in
	// bot_args for contract visibility; Issue.ParentID is the store-canonical
	// pointer (kept in sync on create). Distinct from blockers.
	BotArgSpawnedFrom = "spawned_from"
	// BotArgRole — optional planner|producer hint for UI grouping. Prefer
	// stamping on planner-published tickets (role=producer) and planner roots
	// (role=planner); empty = infer from parent/children.
	BotArgRole = "role"
)

Well-known BotArgs keys for cross-bot multi-pipeline tickets. Iterion does not interpret Town/game-specific semantics beyond what admission and the /pipelines UI need; bots own the request JSON on disk pointed by InputPathKey.

See docs/native-tracker.md § Ticket contract (bot_args) and ADR-076.

Variables

ContractDisplayKeys are bot_args keys the /pipelines drawer surfaces in a dedicated “Contract” strip (not buried in a generic key dump).

View Source
var ErrLabelEmpty = errors.New("native store: label name cannot be empty")

ErrLabelEmpty is returned when a label vocabulary op is called with an empty label name (RenameLabel, MergeLabels, DeleteLabel).

View Source
var ErrStateNotEmpty = errors.New("native store: state has issues; migration target required")

ErrStateNotEmpty is returned by DeleteState when the target column still holds issues and no migration target was supplied. The HTTP layer maps it to 409 so the UI can prompt for a destination column.

Functions

func AutoReadyFromArgs added in v1.0.0

func AutoReadyFromArgs(args map[string]string) bool

AutoReadyFromArgs reports whether bot_args request auto-promotion to Ready when the last hard blocker becomes done.

func BlockerSatisfied added in v1.0.0

func BlockerSatisfied(iss *Issue) bool

BlockerSatisfied reports whether one issue satisfies a hard dependency. Product rule (multi-pipeline / Town): only StateDone counts. Terminal non-success states (e.g. blocked = "won't do") must NOT unblock dependents — that was the gap that made StateBlocked unusable as a temporary hold.

func CanLaunch added in v1.0.0

func CanLaunch(g IssueGetter, iss *Issue) bool

CanLaunch is the unified admission rule for a pipeline / dispatcher ticket:

has a non-empty bot
AND state is StateReady (the only launch-eligible staging state for /pipelines)
AND every hard blocker is StateDone
AND optional require_blocker_labels on those blockers are present

The studio launch loop and the dispatcher adapter share this helper so a ticket cannot slip through one path while being gated by the other. Run-store freshness (already-finished last run, etc.) is a separate check owned by the admission loop — this is the board-side gate only.

func IssueHasAllLabels added in v1.0.0

func IssueHasAllLabels(iss *Issue, labels []string) bool

IssueHasAllLabels reports whether iss carries every required label.

func LaunchBlockedReason added in v1.0.0

func LaunchBlockedReason(g IssueGetter, iss *Issue) string

LaunchBlockedReason returns a short machine reason when CanLaunch would refuse, or "" when the board-side gate is clear.

func NormalizeBlockers added in v1.0.0

func NormalizeBlockers(ids []string) []string

NormalizeBlockers trims, drops empties, and dedupes while preserving order.

func OpenBlockerCount added in v1.0.0

func OpenBlockerCount(g IssueGetter, ids []string) int

OpenBlockerCount is the number of unsatisfied hard blockers (state-only).

func PromoteUnblockedDependents added in v1.0.0

func PromoteUnblockedDependents(store BoardStore, closedID string) error

PromoteUnblockedDependents is the BoardStore-facing auto-promote used after a ticket reaches StateDone (Mongo store, or any backend without an in-mutex index). Dependents in StateWaitingDeps whose hard blockers are now all satisfied move to UnblockTarget. Best-effort: a failed promote does not roll back the closed ticket. The filesystem *Store uses a locked sibling inside SetState instead of this helper to avoid re-lock.

func RequireBlockerLabels added in v1.0.0

func RequireBlockerLabels(args map[string]string) []string

RequireBlockerLabels parses bot_args.require_blocker_labels into a clean list.

func UnblockTarget added in v1.0.0

func UnblockTarget(board *Board, iss *Issue) string

UnblockTarget returns the state a waiting_deps ticket should move to when its last hard blocker becomes done. Default: backlog (human decides Ready); opt-in StateReady when bot_args.auto_ready is truthy. Empty when the board lacks the preferred target (caller keeps the ticket put).

func UpgradeBoardSchema added in v0.50.0

func UpgradeBoardSchema(b *Board) bool

UpgradeBoardSchema applies the in-place upgrades a board persisted by an older iterion needs to work with the current dispatcher, returning true when it modified the board. Shared by the filesystem store (loadOrInitBoard, which persists the result) and the Mongo store (Board(), which normalizes on read). Idempotent; operator-customised boards keep their ordering.

  • `inbox` (bot-emitted findings land there) is prepended when missing.
  • `waiting_deps` (tickets with open hard blockers) is inserted right after `ready` when missing; if there is no `ready`, after `backlog`. Boards with neither are left untouched — the launch gate still works via open_blocker_count alone.
  • `awaiting_input` (the dispatcher parks a paused card there) is inserted right after `in_progress` when missing. Boards without an `in_progress` state are fully custom — left untouched; the dispatcher's "stays in place" fallback covers them.

func UpsertKey added in v1.0.0

func UpsertKey(bot string, args map[string]string) (botName, inputPath string, ok bool)

UpsertKey returns the (bot, input_path) pair used for ticket reconcile, or ("","") when upsert is not possible.

func ValidateBlockers added in v1.0.0

func ValidateBlockers(g IssueGetter, id string, blockers []string) error

ValidateBlockers returns an error when the proposed blockers list would create a cycle for issue id. Missing IDs are allowed (fail-closed at launch); only cycles are rejected at write time.

func WouldCreateCycle added in v1.0.0

func WouldCreateCycle(g IssueGetter, id string, blockers []string) bool

WouldCreateCycle reports whether setting issue id's blockers to the given list would introduce a cycle (A→B→A). Missing blocker IDs cannot form a path and are ignored; a self-reference is always a cycle.

Types

type Adapter

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

Adapter exposes a BoardStore under the tracker.Tracker interface so the dispatcher can dispatch board issues with the same code path that drives external trackers (GitHub, Forgejo). It uses only BoardStore methods, so it wraps either the filesystem *Store (self-hosted) or the Mongo store (boardmongo, cloud) unchanged.

func NewAdapter

func NewAdapter(store BoardStore) *Adapter

NewAdapter wraps a board store as a tracker.Tracker.

func (*Adapter) Claim

func (a *Adapter) Claim(ctx context.Context, id, marker string) error

Claim delegates to the store.

func (*Adapter) Comment

func (a *Adapter) Comment(ctx context.Context, id, body string) error

Comment appends a note to the issue's discussion thread under the "dispatcher" author — the dispatcher and finalize hooks are the callers that leave a trail (e.g. the MR/PR back-link a run posts at the end). Operator-authored comments arrive via the REST endpoint, which calls Store.AddComment directly with the operator as author.

func (*Adapter) LastRunForIssue

func (a *Adapter) LastRunForIssue(id string) (string, error)

LastRunForIssue returns the runID of the most recent dispatch on this issue. Empty when the issue has never been dispatched (or the issue does not exist). Used by the dispatcher's resume path as a cross-daemon-restart fallback: in-memory retry entries vanish when the daemon restarts, but the issue record on disk preserves LastRunID so the next dispatch can still find the prior run and resume from its checkpoint.

func (*Adapter) ListAwaitingInput added in v0.50.0

func (a *Adapter) ListAwaitingInput(marker string) ([]tracker.Issue, error)

ListAwaitingInput returns the cards parked in the awaiting-input column that this dispatcher may reconcile: unclaimed (post-restart, after the stale-claim sweep) or claimed by the given marker. Cards claimed by ANOTHER live daemon sharing the store are excluded — their owner reconciles them. Consumed by the dispatcher's parked sweep (reconcileParked in pkg/dispatcher) via optional-interface assertion, same seam as SetLastRun/SetAwaitingInput.

func (*Adapter) ListCandidates

func (a *Adapter) ListCandidates(ctx context.Context) ([]tracker.Issue, error)

ListCandidates returns unclaimed issues whose state is marked eligible on the board, excluding those whose hard blockers are not all StateDone (see BlockersSatisfied / CanLaunch). Missing blockers are treated as open (fail closed). Terminal non-success states such as StateBlocked do NOT satisfy a dependency.

func (*Adapter) Name

func (a *Adapter) Name() string

Name implements tracker.Tracker.

func (*Adapter) RefreshStates

func (a *Adapter) RefreshStates(ctx context.Context, ids []string) (map[string]string, error)

RefreshStates returns the current state for each requested ID; missing IDs are omitted.

func (*Adapter) Release

func (a *Adapter) Release(ctx context.Context, id, marker string) error

Release delegates to the store.

func (*Adapter) SetAwaitingInput added in v0.50.0

func (a *Adapter) SetAwaitingInput(id string, v bool) error

SetAwaitingInput passes through to the underlying store so the dispatcher's optional-interface type assertion (setAwaitingInput in commands.go) resolves — without this pass-through the seam falls through silently and no card ever gets its awaiting-input badge (the SetLastRun regression above, avoided here).

func (*Adapter) SetLastRun

func (a *Adapter) SetLastRun(id, runID, workdir string) error

SetLastRun stamps the (runID, workdir) pair on the issue so the dispatcher can pivot from a kanban card back to the run that processed it (studio's IssueModal, the resume fallback in loop.go). Phase 3 (commit 9835ae29) added Store.SetLastRun but forgot the Adapter pass-through — without this method the dispatcher's type assertion in stampLastRun fell through silently and no issue ever got its LastRunID populated. Adapter exposes the method so c.tracker.(SetLastRun-interface) resolves.

func (*Adapter) SweepStaleClaims

func (a *Adapter) SweepStaleClaims(isStale func(marker string) bool) ([]string, error)

SweepStaleClaims walks every claimed issue and releases the claim when isStale reports true for its marker. Returns the issue IDs whose claim was cleared. Caller-supplied predicate keeps PID/host knowledge out of the native store — typically the dispatcher passes a callback that recognises its own "<hostname>-<pid>" markers and probes the local kernel for a live process.

func (*Adapter) UpdateState

func (a *Adapter) UpdateState(ctx context.Context, id, newState string) error

UpdateState delegates to the store.

type BlockerInfo added in v1.0.0

type BlockerInfo struct {
	ID        string   `json:"id"`
	Title     string   `json:"title,omitempty"`
	State     string   `json:"state,omitempty"`
	Bot       string   `json:"bot,omitempty"`
	Labels    []string `json:"labels,omitempty"`
	Satisfied bool     `json:"satisfied"`
	// MissingLabels lists required labels still absent when state is done
	// but bot_args.require_blocker_labels is set on the dependent.
	MissingLabels []string `json:"missing_labels,omitempty"`
}

BlockerInfo is a resolved dependency for projection and API responses.

func BlockersSatisfied added in v1.0.0

func BlockersSatisfied(g IssueGetter, ids []string) (ok bool, open []BlockerInfo)

BlockersSatisfied returns ok when every blocker is StateDone (no label policy).

func BlockersSatisfiedForIssue added in v1.0.0

func BlockersSatisfiedForIssue(g IssueGetter, iss *Issue) (ok bool, open []BlockerInfo)

BlockersSatisfiedForIssue applies the dependent's bot_args policy (require_blocker_labels) to its blockers list.

func BlockersSatisfiedPolicy added in v1.0.0

func BlockersSatisfiedPolicy(g IssueGetter, ids []string, policy BlockerPolicy) (ok bool, open []BlockerInfo)

BlockersSatisfiedPolicy returns ok when every blocker passes state+labels.

func ResolveBlockers added in v1.0.0

func ResolveBlockers(g IssueGetter, ids []string) []BlockerInfo

ResolveBlockers enriches blocker IDs with the default (state-only) policy.

func ResolveBlockersForIssue added in v1.0.0

func ResolveBlockersForIssue(g IssueGetter, iss *Issue) []BlockerInfo

ResolveBlockersForIssue is the projection counterpart of BlockersSatisfiedForIssue.

func ResolveBlockersPolicy added in v1.0.0

func ResolveBlockersPolicy(g IssueGetter, ids []string, policy BlockerPolicy) []BlockerInfo

ResolveBlockersPolicy enriches blocker IDs. Missing IDs are unsatisfied (fail closed). When policy.RequireLabels is set, a done blocker still fails until it carries every required label.

type BlockerPolicy added in v1.0.0

type BlockerPolicy struct {
	// RequireLabels must all be present on each blocker once it is done.
	RequireLabels []string
}

BlockerPolicy optional gates beyond state==done (V3.2 artefact acceptance).

type BlockingInfo added in v1.0.0

type BlockingInfo struct {
	ID    string `json:"id"`
	Title string `json:"title,omitempty"`
}

BlockingInfo is one reverse-index entry: an issue that lists this one as a blocker. Computed on read (V1 — not stored).

func ReverseBlockers added in v1.0.0

func ReverseBlockers(all []*Issue, id string) []BlockingInfo

ReverseBlockers builds the reverse index for one issue id: every other issue that lists it in Blockers. Computed on read (V1).

type Board

type Board struct {
	States    []State   `json:"states"`
	Fields    []Field   `json:"fields,omitempty"`
	Views     []View    `json:"views,omitempty"`
	UpdatedAt time.Time `json:"updated_at"`
}

Board is the kanban configuration: ordered states + custom field schema + saved views.

func DefaultBoard

func DefaultBoard() *Board

DefaultBoard returns the recommended starter board.

`inbox` is the leftmost state and receives bot-emitted findings — short observations that aren't worth dispatching alone (a doc drift, a security smell, a bug surfaced during a feature run). Operators triage by dragging inbox → backlog (promote) or deleting the card (dismiss). Not eligible: the dispatcher never auto-picks inbox.

Includes the `bot_args` custom field that the dispatcher reads at dispatch time (encoded `--var key=value` overrides per ticket). Bots like whats-next set this on create_issue; without it in the default schema, fresh local stores reject the field with `unknown field "bot_args"` and the bot wastes turns retrying.

func (*Board) FieldByName

func (b *Board) FieldByName(name string) *Field

FieldByName returns the field matching name, or nil.

func (*Board) StateByName

func (b *Board) StateByName(name string) *State

StateByName returns the state matching name, or nil.

func (*Board) Validate

func (b *Board) Validate() error

Validate checks the board is internally consistent. Returns nil on success.

func (*Board) ValidateFieldValues

func (b *Board) ValidateFieldValues(values map[string]any) error

ValidateFieldValues checks a map of custom field values against the board schema. Unknown fields or wrong types fail. Required fields must be present.

type BoardAPI

type BoardAPI struct {
	// Resolve returns the board store for this request, or an error. A nil
	// store (no error) is treated as "board not available" (404). In cloud
	// mode it extracts the team from the path/identity and returns
	// CloudBoardFor(team); the membership gate lives in the mount wrapper.
	Resolve func(r *http.Request) (BoardStore, error)
}

BoardAPI serves the kanban REST surface against a BoardStore resolved PER REQUEST. Local/self-hosted mode resolves a single constant store (the filesystem *Store); cloud mode resolves the caller's tenant board (boardmongo.Store keyed by team) so one server serves every team's board from the same routes. The handlers only touch the BoardStore interface (+ the optional BoardAdmin / commentDispatcherSource capabilities), so the same code drives both backends.

func (*BoardAPI) RegisterRoutesWithMiddleware

func (h *BoardAPI) RegisterRoutesWithMiddleware(mux *http.ServeMux, prefix string, wrap func(http.Handler) http.Handler)

RegisterRoutesWithMiddleware mounts the kanban REST routes under prefix, each wrapped by wrap (nil = identity). One pattern per (method, path) so Go 1.22's ServeMux doesn't flag ambiguities against other catch-all method routes. The prefix may itself contain path wildcards (e.g. "/api/teams/{tid}/board") that the resolver reads back via PathValue.

type BoardAdmin

type BoardAdmin interface {
	AddState(st State) error
	RenameState(from, to string) (int, error)
	DeleteState(name, migrateTo string) (int, error)
	UpdateState(name string, p StatePatch) error
	ReorderStates(order []string) error
	AddField(f Field) error
	UpdateField(name string, p FieldPatch) error
	RenameField(from, to string) (int, error)
	DeleteField(name string) (int, error)
	ReorderFields(order []string) error
	SaveView(v View) error
	DeleteView(name string) error
	RenameLabel(from, to string) (int, error)
	MergeLabels(from, to string) (int, error)
	DeleteLabel(label string) (int, error)
}

BoardAdmin is the optional board-CONFIG-mutation capability: editing columns, custom fields, saved views and the label vocabulary, including the cascades to issues (a column rename follows its cards). The filesystem *Store implements it; the cloud Mongo store does not yet, so cloud board-config editing returns 501 (the board itself can still be replaced wholesale via PUT /board, which is a plain BoardStore.SetBoard).

type BoardStore

type BoardStore interface {
	Board() *Board
	SetBoard(b *Board) error

	Create(in Issue) (*Issue, error)
	Get(id string) (*Issue, error)
	List(filter ListFilter) ([]*Issue, error)
	Update(id string, p Patch) (*Issue, error)
	SetState(id, newState string) (*Issue, error)
	Delete(id string) error

	// Claim/Release are the dispatcher's per-issue lease (marker = the
	// dispatcher instance id). SetLastRun records the run a dispatch spawned
	// so a cross-restart resume can find it.
	Claim(id, marker string) error
	Release(id, marker string) error
	SetLastRun(id, runID, workdir string) error
	// SetAwaitingInput denormalizes onto the issue whether its most recent
	// run parked awaiting human/operator input, so the board grid can badge
	// the card without a per-run fetch. A best-effort HINT (see Issue.AwaitingInput).
	SetAwaitingInput(id string, v bool) error

	// AddComment appends a note to the issue's discussion thread and
	// returns the updated issue plus the created comment.
	AddComment(id, author, body string) (*Issue, *Comment, error)

	Resolve(prefix string) (string, error)
	ScanEvents(visit func(*Event) bool) error
	AggregateLabels() []LabelUsage
}

BoardStore is the storage contract the board operations (boardops), the dispatcher tracker adapter, and the REST handlers operate against. The filesystem-backed *Store satisfies it; a cloud build can supply a Mongo-backed implementation of the SAME contract so the shared boardops and dispatcher run unchanged against either backend.

The board domain types (Issue, Patch, ListFilter, Board, Event, LabelUsage) live in this package; a non-filesystem implementation imports them from here. They are plain JSON/BSON-friendly structs with no filesystem coupling, so this is types-only reuse, not behaviour.

type Comment

type Comment struct {
	ID        string    `json:"id"`
	Author    string    `json:"author,omitempty"`
	Body      string    `json:"body"`
	CreatedAt time.Time `json:"created_at"`
}

Comment is a single append-only note on a native issue. Author is a free-form display name ("operator", a bot persona, "system"); an empty Author renders as "anonymous" downstream.

type CommentDispatcher

type CommentDispatcher func(iss Issue, commentBody string) (bot string, botArgs map[string]string, transitionTo string, ok bool)

CommentDispatcher resolves a board-issue comment that leads with a "/command" into a bot launch: the bot to assign, the per-run bot_args (including the open_mr / source_issue_ref stamp for an opens-MR command), and the dispatch-eligible state to move the issue to. ok=false means "just record the comment, launch nothing". Installed by the server via SetCommentDispatcher; nil in a bare store (a plain `iterion dispatch` daemon or a unit test), where the comment is recorded with no dispatch — exactly the prior behaviour.

type Event

type Event struct {
	Seq       int64          `json:"seq"`
	Timestamp time.Time      `json:"timestamp"`
	Type      EventType      `json:"type"`
	IssueID   string         `json:"issue_id,omitempty"`
	Payload   map[string]any `json:"payload,omitempty"`
}

Event is the audit-log record persisted to events.jsonl. Seq is a monotonic per-tracker counter; Timestamp is UTC.

type EventType

type EventType string

EventType enumerates the kinds of events the native tracker emits.

const (
	EvtIssueCreated  EventType = "issue_created"
	EvtIssueUpdated  EventType = "issue_updated"
	EvtIssueState    EventType = "issue_state_changed"
	EvtIssueDeleted  EventType = "issue_deleted"
	EvtIssueClaimed  EventType = "issue_claimed"
	EvtIssueReleased EventType = "issue_released"
	EvtIssueLastRun  EventType = "issue_last_run_updated"
	EvtIssueComment  EventType = "issue_comment_added"
	// EvtIssueBlockersUpdated is emitted when an issue's blockers list changes
	// (create-with-blockers, Update patch). Payload: {blockers: []string}.
	EvtIssueBlockersUpdated EventType = "issue_blockers_updated"
	// EvtIssueUnblocked is emitted when a waiting_deps ticket is auto-
	// promoted because its last hard blocker reached StateDone. Payload:
	// {from, to, closed_blocker}.
	EvtIssueUnblocked EventType = "issue_unblocked"
	EvtBoardUpdated   EventType = "board_updated"
	// Label-vocabulary management events, emitted once per touched
	// issue. The payload carries `{from, to}` for rename/merge and
	// `{label}` for delete.
	EvtLabelRename EventType = "label_rename"
	EvtLabelMerge  EventType = "label_merge"
	EvtLabelDelete EventType = "label_delete"
)

type ExternalRef

type ExternalRef struct {
	Provider     string `json:"provider"`
	ConnectionID string `json:"connection_id"`
	Repo         string `json:"repo"`
	Number       int    `json:"number"`
	URL          string `json:"url,omitempty"`
	State        string `json:"state,omitempty"`
	// Author is the forge login that opened the external issue — the identity
	// the author-trust gate classified at ingest, kept so operators can see
	// WHO requested a parked card before approving its triage.
	Author string `json:"author,omitempty"`
}

ExternalRef links a board card to an issue on an external forge. Set by the forge→board sync worker and the push-to-forge action; read by the card PR/CI panel and push handler. Provider is "github"|"gitlab"|"forgejo".

type Field

type Field struct {
	Name       string    `json:"name"`
	Display    string    `json:"display,omitempty"`
	Type       FieldType `json:"type"`
	Required   bool      `json:"required,omitempty"`
	EnumValues []string  `json:"enum_values,omitempty"`
	Default    any       `json:"default,omitempty"`
}

Field is a custom field definition.

type FieldPatch

type FieldPatch struct {
	Display    *string    `json:"display,omitempty"`
	Type       *FieldType `json:"type,omitempty"`
	Required   *bool      `json:"required,omitempty"`
	EnumValues *[]string  `json:"enum_values,omitempty"`
}

FieldPatch carries the editable definition fields for UpdateField. A nil pointer leaves the corresponding attribute untouched. Renames go through RenameField (they cascade), never here.

type FieldType

type FieldType string

FieldType enumerates the supported custom-field value kinds.

const (
	FieldText   FieldType = "text"
	FieldNumber FieldType = "number"
	FieldEnum   FieldType = "enum"
	FieldDate   FieldType = "date"
	FieldBool   FieldType = "bool"
)

type Issue

type Issue struct {
	ID       string   `json:"id"`
	Title    string   `json:"title"`
	Body     string   `json:"body,omitempty"`
	State    string   `json:"state"`
	Labels   []string `json:"labels,omitempty"`
	Priority int      `json:"priority,omitempty"`
	Assignee string   `json:"assignee,omitempty"`
	Blockers []string `json:"blockers,omitempty"`
	// ParentID is the planner (or prior planner) ticket that spawned this
	// one. Distinct from Blockers (scheduling deps): ParentID is provenance
	// / campaign ownership so the /pipelines UI can nest children under a
	// plan. Empty for root tickets. Stamped automatically by board.create
	// when the creating run is sourced from a ticket, or set explicitly via
	// create_issue parent_id / bot_args.spawned_from.
	ParentID string         `json:"parent_id,omitempty"`
	Fields   map[string]any `json:"fields,omitempty"`
	// Bot, when non-empty, overrides the dispatcher's per-assignee /
	// global workflow selection for this ticket. The dispatcher
	// resolves the name to a workflow file via pkg/botregistry.
	Bot string `json:"bot,omitempty"`
	// BotArgs are per-ticket overrides merged on top of the
	// dispatcher config's templated vars at launch time (key-by-key:
	// BotArgs wins for declared keys, config templates fill the rest).
	// Values are stored as strings so the engine's existing var-coercion
	// pipeline applies — same wire format as the studio's Launch form.
	BotArgs map[string]string `json:"bot_args,omitempty"`
	Claim   string            `json:"claim,omitempty"`
	// LastRunID is the most recent dispatcher-spawned run that
	// processed this issue. Stamped by the dispatcher's finishRun
	// regardless of success/failure so the operator can always
	// pivot from the kanban card to the run console / diff inspector.
	LastRunID string `json:"last_run_id,omitempty"`
	// AwaitingInput is a denormalized best-effort HINT that the issue's
	// most recent dispatcher-spawned run parked on a human/operator gate
	// and is waiting for an answer. It lets the studio render a per-card
	// "⏸ Awaiting input" badge on the board grid WITHOUT an N+1 run
	// fetch. The dispatcher sets it true when a run parks on pause and
	// clears it on the paths it controls (clean terminal finish,
	// re-dispatch). It is NOT authoritative — the IssueModal's answer
	// affordance still keys off getRun(last_run_id).status; a stale flag
	// (e.g. after a console-only resume the dispatcher never observed) is
	// corrected at the next card touch.
	AwaitingInput bool `json:"awaiting_input,omitempty"`
	// LastWorkdir is the absolute filesystem path the last run
	// executed in — either the per-issue dispatcher workspace or,
	// when `worktree: auto` was used, the run's git worktree path.
	// The studio exposes it as a copy-to-clipboard / vscode://file
	// link so the operator can inspect the diff manually.
	LastWorkdir string `json:"last_workdir,omitempty"`
	// Runs is the append-only history of dispatcher-spawned runs that
	// processed this issue, newest-last. LastRunID/LastWorkdir remain the
	// single overwritten pointer to the most-recent run for back-compat;
	// Runs is the full history the studio renders as a list. Deduped by
	// RunID (see AppendRunRef). Absent on records written before T4a.
	Runs      []RunRef  `json:"runs,omitempty"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	// External links this card to an issue on an external forge — set when
	// the card is mirrored FROM a forge (one-way forge→board sync) or pushed
	// TO one (push-to-forge). It is metadata: the card's column stays
	// operator-owned. Repo doubles as the board swimlane key (repo-per-lane).
	External *ExternalRef `json:"external,omitempty"`
	// Comments is the append-only discussion thread on the issue. Used
	// by hooks / the dispatcher to leave a dispatch trail, by the studio
	// IssueModal, and — once the comment-trigger wiring lands — to carry
	// operator `/command` requests and the resulting MR/PR back-links.
	Comments []Comment `json:"comments,omitempty"`
}

Issue is the native tracker's source-of-truth issue record. The dispatcher consumes a normalized view via tracker.Issue (see pkg/dispatcher/tracker/native.go for the conversion).

func FindByBotInputPath added in v1.0.0

func FindByBotInputPath(store BoardStore, bot, inputPath string) (*Issue, error)

FindByBotInputPath returns the first issue matching bot + bot_args.input_path. Used by pipeline-board upsert. Match is exact on both strings.

type IssueGetter added in v1.0.0

type IssueGetter interface {
	Get(id string) (*Issue, error)
}

IssueGetter is the minimal lookup surface hard-blocker evaluation needs. *Store and every BoardStore implementation satisfy it via Get.

type LabelUsage

type LabelUsage struct {
	Label      string `json:"label"`
	Count      int    `json:"count"`
	LastUsedAt string `json:"last_used_at,omitempty"` // RFC3339; empty when no timestamp survived the scan.
}

LabelUsage is one row of the AggregateLabels result.

type ListFilter

type ListFilter struct {
	States   []string
	Labels   []string
	Assignee string
	Claimed  *bool
}

ListFilter constrains the result of List. Zero-value fields don't filter.

type Patch

type Patch struct {
	Title    *string
	Body     *string
	Labels   *[]string
	Priority *int
	Assignee *string
	Blockers *[]string
	// ParentID, when non-nil, sets the planner provenance pointer (empty
	// string clears it). Distinct from Blockers.
	ParentID *string
	// Fields is merged into the issue's Fields. A nil value deletes the key.
	Fields map[string]any
	// Bot, when non-nil, sets the per-ticket bot override (empty string
	// clears it). The dispatcher resolves it to a workflow at launch.
	Bot *string
	// BotArgs, when non-nil, replaces the issue's bot args wholesale
	// (a nil map deletes; an empty map clears with no entries). This
	// mirrors how Labels and Blockers are handled — the entire
	// collection swaps. Per-key partial updates aren't useful because
	// the studio always sends the full form state.
	BotArgs *map[string]string
	// External, when non-nil, sets the card's forge linkage (the
	// forge→board sync worker refreshes url/state; push-to-forge stamps a
	// previously-unlinked card). A nil pointer leaves the existing link.
	External *ExternalRef
}

Patch describes a partial update to an issue. Pointer fields are nil when the corresponding field is not being changed.

type RunRef added in v0.50.0

type RunRef struct {
	RunID   string    `json:"run_id"`
	Workdir string    `json:"workdir,omitempty"`
	At      time.Time `json:"at"`
}

RunRef is one entry in an issue's run history (Issue.Runs). RunID is the dispatcher-spawned run id; Workdir is the absolute path it executed in (per-issue workspace or a `worktree: auto` git worktree); At is when the run was stamped onto the card.

func AppendRunRef added in v0.50.0

func AppendRunRef(runs []RunRef, runID, workdir string, at time.Time) []RunRef

AppendRunRef dedup-appends a run onto an issue's history keyed on RunID: if runID is already present its Workdir/At are updated in place; otherwise a new RunRef is appended (newest-last). Shared by both the native and boardmongo SetLastRun implementations so the append semantics stay identical across stores. Growth is uncapped by design.

type State

type State struct {
	Name     string `json:"name"`
	Display  string `json:"display,omitempty"`
	Color    string `json:"color,omitempty"`
	Terminal bool   `json:"terminal,omitempty"`
	Eligible bool   `json:"eligible,omitempty"`
}

State is one kanban column in the board.

type StatePatch

type StatePatch struct {
	Display  *string `json:"display,omitempty"`
	Color    *string `json:"color,omitempty"`
	Eligible *bool   `json:"eligible,omitempty"`
	Terminal *bool   `json:"terminal,omitempty"`
}

StatePatch carries the editable per-column fields for UpdateState. Nil pointers leave the corresponding field untouched.

type Store

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

Store is the filesystem-backed native tracker store. Safe for concurrent use.

func NewStore

func NewStore(root string) (*Store, error)

NewStore opens (or initializes) the native tracker at root. If board.json is absent a default board is written.

func (*Store) AddComment

func (s *Store) AddComment(id, author, body string) (updated *Issue, comment *Comment, err error)

AddComment appends a note to the issue's discussion thread and returns the updated issue plus the created comment. Author is a free-form display name; body must be non-empty. The append is persisted to issues/<id>.json and an EvtIssueComment record is emitted so external tailers (studio, webhook bridge) observe new comments.

func (*Store) AddField

func (s *Store) AddField(f Field) (err error)

AddField appends a new custom-field definition. Rejects empty/duplicate names; the candidate board is validated (enum needs values, known type).

func (*Store) AddState

func (s *Store) AddState(st State) (err error)

AddState appends a new column to the board. The column lands last; the operator reorders afterward via ReorderStates. Rejects an empty or duplicate name. No issue migration.

func (*Store) AggregateLabels

func (s *Store) AggregateLabels() []LabelUsage

AggregateLabels walks the in-memory index and reduces (label → count, max(updated_at)). Sorted by count desc, label asc for deterministic output. Used by the REST /labels endpoint, the boardops list_labels MCP tool, and the studio's label-picker.

func (*Store) Board

func (s *Store) Board() *Board

Board returns a defensive copy of the current board config.

func (*Store) Claim

func (s *Store) Claim(id, marker string) (err error)

Claim sets the claim marker. Returns tracker.ErrClaimConflict if the issue is already claimed by a different marker. Idempotent for the same marker.

func (*Store) Close

func (s *Store) Close() error

Close releases store-owned resources (currently the fsnotify watcher goroutine). Safe to call multiple times; safe on a Store whose watcher never started.

func (*Store) Create

func (s *Store) Create(in Issue) (created *Issue, err error)

Create persists a new issue. The State must be one of the configured board states; if empty, the first state is used. ID is generated if missing.

func (*Store) Delete

func (s *Store) Delete(id string) (err error)

Delete removes the issue file and emits an issue_deleted event.

func (*Store) DeleteField

func (s *Store) DeleteField(name string) (touched int, err error)

DeleteField removes a field definition and strips its key from every issue (so no issue keeps a value the schema no longer validates).

func (*Store) DeleteLabel

func (s *Store) DeleteLabel(label string) (int, error)

DeleteLabel strips `label` from every issue that carries it. Returns the count of issues touched.

func (*Store) DeleteState

func (s *Store) DeleteState(name, migrateTo string) (touched int, err error)

DeleteState removes a column. If it still holds issues, migrateTo must name another existing column to receive them (else ErrStateNotEmpty). Refuses to delete the last remaining column. Issues are migrated first, then the column is dropped, so no issue is ever left in a column that no longer exists. Returns the number of issues migrated.

func (*Store) DeleteView

func (s *Store) DeleteView(name string) (err error)

DeleteView removes a named view. Unknown names error.

func (*Store) Get

func (s *Store) Get(id string) (*Issue, error)

Get returns a defensive copy of the issue with the given ID.

func (*Store) List

func (s *Store) List(filter ListFilter) ([]*Issue, error)

List returns defensive copies of issues matching the filter, sorted by priority desc, then created_at asc. Walks the in-memory index — no filesystem I/O on the hot path.

Note: every match incurs a full cloneIssue under the store mutex. At the current sub-1k-issue usage this is invisible; once a board holds more than ~1k open issues the dispatcher poller (which calls List on every tick) starts to contend with mutators. The cheap remediation is to filter-and-count first under the read lock, drop the lock, then clone outside it — defer until benchmarks show real contention.

func (*Store) MergeLabels

func (s *Store) MergeLabels(from, to string) (int, error)

MergeLabels is rename's near-twin: every issue carrying `from` ends up carrying `to` (and no longer `from`). Differs from Rename only in the audit event payload — emitted as "label_merge" so an operator reviewing events.jsonl can tell whether the operation was a typo fix (rename) or a vocabulary consolidation (merge). Functionally equivalent today.

func (*Store) RegisterRoutes

func (s *Store) RegisterRoutes(mux *http.ServeMux, prefix string)

RegisterRoutes mounts the native tracker's REST surface on mux under prefix against a single constant store. Pass "" to mount at the mux root.

func (*Store) RegisterRoutesWithMiddleware

func (s *Store) RegisterRoutesWithMiddleware(mux *http.ServeMux, prefix string, wrap func(http.Handler) http.Handler)

RegisterRoutesWithMiddleware mounts the routes for this constant store through a caller-supplied wrapper (typically the studio server's requireAuth). It delegates to a BoardAPI whose resolver always returns s, so the self-hosted single-board behaviour is unchanged.

func (*Store) Release

func (s *Store) Release(id, marker string) (err error)

Release clears the claim if it matches the given marker. Releasing an already-unclaimed issue is a no-op.

func (*Store) RenameField

func (s *Store) RenameField(from, to string) (touched int, err error)

RenameField renames a field definition and cascades the key across every issue's Fields map. Refuses renaming onto an existing field.

func (*Store) RenameLabel

func (s *Store) RenameLabel(from, to string) (int, error)

RenameLabel rewrites every occurrence of `from` to `to` across all issues. Returns the number of issues touched. No-op when from == to, returns ErrLabelEmpty if either side is the empty string. Idempotent: running it twice on the same input touches zero issues the second time. Emits one issue_updated event per touched issue (labels changed). The whole pass holds the store mutex so concurrent writers can't race; for boards with thousands of issues that briefly stalls other mutators, which is the acceptable trade-off for atomic-ish vocabulary management.

func (*Store) RenameState

func (s *Store) RenameState(from, to string) (touched int, err error)

RenameState renames a column and cascades the change to every issue in it. Renaming onto an existing column is refused (it would silently merge two columns' semantics — delete-with-migrate is the explicit path for that). Renaming to itself is a no-op. Returns the number of issues touched. The board is renamed first, then issues are migrated, so a mid-cascade failure leaves a renamed column with some issues still carrying the old name (they land in "__unmapped__" until retried).

func (*Store) ReorderFields

func (s *Store) ReorderFields(order []string) (err error)

ReorderFields rewrites the field order. `order` must be a permutation of the current field names. Never touches issues.

func (*Store) ReorderStates

func (s *Store) ReorderStates(order []string) (err error)

ReorderStates rewrites the column order. `order` must be a permutation of the current state names (same set, no missing/extra/duplicate entries). Never migrates issues.

func (*Store) Resolve

func (s *Store) Resolve(prefix string) (string, error)

Resolve returns the full issue ID matching the given prefix. The prefix may be the bare UUID (without the "native:" scheme) or the full ID. Returns tracker.ErrNotFound if no issue matches and a distinct error if multiple match. Walks the in-memory index, so O(N) over distinct issues with no filesystem I/O.

func (*Store) Root

func (s *Store) Root() string

Root returns the on-disk root directory.

func (*Store) SaveView

func (s *Store) SaveView(v View) (err error)

SaveView upserts a named view (replaces by name if it exists, else appends). Rejects an empty name.

func (*Store) ScanEvents

func (s *Store) ScanEvents(visit func(*Event) bool) error

ScanEvents streams events from events.jsonl through visit, in file order. Returning false from visit stops the scan. Safe to call concurrently with writes — the file is append-only.

func (*Store) SetAwaitingInput added in v0.50.0

func (s *Store) SetAwaitingInput(id string, v bool) (err error)

SetAwaitingInput denormalizes onto the issue whether its most recent dispatcher-spawned run parked awaiting human/operator input (see Issue.AwaitingInput). Idempotent — setting the flag to its current value is a no-op (no write, no event). Follows the SetLastRun shape: read → set → write → bump UpdatedAt → emit EvtIssueUpdated so tailers (studio) refresh the card badge.

func (*Store) SetBoard

func (s *Store) SetBoard(b *Board) (err error)

SetBoard validates and replaces the board configuration. The disk write happens BEFORE the in-memory swap so a write failure leaves both the live store and on-disk state consistent on the old board — the previous order (swap → write) silently diverged in-memory from disk on EIO / quota / permission errors (F-CD-9).

SetBoard does NOT migrate issues: replacing the state list here leaves issues pointing at states that may no longer exist (they fall into the studio's "__unmapped__" bucket). Use it only for whole-board seeds and no-migration edits. Column renames/deletes that must move issues go through RenameState/DeleteState, which cascade across the issue files.

func (*Store) SetCommentDispatcher

func (s *Store) SetCommentDispatcher(d CommentDispatcher)

SetCommentDispatcher installs the slash-command resolver consulted by the POST /issues/{id}/comments handler. Called once at wiring time.

func (*Store) SetLastRun

func (s *Store) SetLastRun(id, runID, workdir string) (err error)

SetLastRun stamps the most recent dispatcher-spawned run that processed the issue onto its record. Idempotent — passing the same runID + workdir as the current values is a no-op (no write, no event). Empty strings are written as-is so the operator can clear the stamp if needed.

The dispatcher calls this on every finishRun (success or failure) so the studio's IssueModal can always link back to the most recent run that touched the issue.

func (*Store) SetLogger added in v0.50.0

func (s *Store) SetLogger(l *iterlog.Logger)

SetLogger replaces the store's diagnostic logger (default: warn-level to stderr). Lets studio/dispatch plumb their configured logger in. Nil is ignored so callers never disable diagnostics by accident.

func (*Store) SetState

func (s *Store) SetState(id, newState string) (updated *Issue, err error)

SetState transitions an issue, validating against the board. Returns tracker.ErrTransitionRejected if newState is unknown. When the new state is StateDone, dependents parked in StateWaitingDeps whose hard blockers are now all satisfied are auto-promoted (default → backlog, or → ready when bot_args.auto_ready is set) and emit issue_unblocked.

func (*Store) Subscribe

func (s *Store) Subscribe(fn func(Event)) (func(), error)

Subscribe starts a goroutine that tails events.jsonl and calls fn for each newly-appended Event (in file order, after the current EOF). Returns a cancel func that stops the tailer and releases the fsnotify resources. Returns a nil cancel + error when fsnotify is unavailable on the host (read-only / kernel-restricted environment); callers should log and continue with fan-out disabled — the same degradation the index watcher already accepts.

fn runs on the tailer goroutine and must not block for long; offload slow work (store I/O) to the caller's own goroutine if needed.

func (*Store) Update

func (s *Store) Update(id string, p Patch) (updated *Issue, err error)

Update applies the patch and emits an issue_updated event with the list of changed top-level fields. State changes are not supported here; use SetState.

func (*Store) UpdateField

func (s *Store) UpdateField(name string, p FieldPatch) (err error)

UpdateField edits a field definition in place (no rename, no value migration). The amended board is validated before commit.

func (*Store) UpdateState

func (s *Store) UpdateState(name string, p StatePatch) (err error)

UpdateState edits a column's display name, color, and eligible/terminal flags. It never renames (that cascades — use RenameState) and never migrates issues.

type View

type View struct {
	Name     string   `json:"name"`
	Search   string   `json:"search,omitempty"`
	Labels   []string `json:"labels,omitempty"`
	Assignee string   `json:"assignee,omitempty"`
	// Bot scopes the view to a single bot (Issue.Bot). Additive to the
	// group-by-bot swimlane lens: this is a persisted FILTER, so an
	// operator can save "the X pipeline" as a saved View.
	Bot     string `json:"bot,omitempty"`
	Sort    string `json:"sort,omitempty"`
	GroupBy string `json:"group_by,omitempty"`
}

View is a saved board filter/sort/group preset. Shared across operators via board.json; the studio's view picker loads one to restore the search query, label/assignee filters, card sort, and swimlane grouping.

Directories

Path Synopsis
Package boardops contains the capability-gated operations that the __mcp-board MCP server and the /api/v1/mcp/board HTTP handler share.
Package boardops contains the capability-gated operations that the __mcp-board MCP server and the /api/v1/mcp/board HTTP handler share.

Jump to

Keyboard shortcuts

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