feat: add type: event for operational state changes (bd-ecmd)
Adds support for event beads that capture operational state transitions as immutable records. Events are a new issue type with fields: - event_kind: namespaced category (patrol.muted, agent.started) - actor: entity URI who caused the event - target: entity URI or bead ID affected - payload: event-specific JSON data This enables: - bd activity --follow showing events - bd list --type=event --target=agent:deacon - Full audit trail for operational state - HOP-compatible transaction records 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
This commit is contained in:
@@ -47,8 +47,9 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
|
||||
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters, mol_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
await_type, await_id, timeout_ns, waiters, mol_type,
|
||||
event_kind, actor, target, payload
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
|
||||
issue.AcceptanceCriteria, issue.Notes, issue.Status,
|
||||
@@ -59,6 +60,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
|
||||
issue.Sender, wisp, pinned, isTemplate,
|
||||
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
|
||||
string(issue.MolType),
|
||||
issue.EventKind, issue.Actor, issue.Target, issue.Payload,
|
||||
)
|
||||
if err != nil {
|
||||
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
||||
@@ -80,8 +82,9 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
||||
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters, mol_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
await_type, await_id, timeout_ns, waiters, mol_type,
|
||||
event_kind, actor, target, payload
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
@@ -117,6 +120,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
||||
issue.Sender, wisp, pinned, isTemplate,
|
||||
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
|
||||
string(issue.MolType),
|
||||
issue.EventKind, issue.Actor, issue.Target, issue.Payload,
|
||||
)
|
||||
if err != nil {
|
||||
// INSERT OR IGNORE should handle duplicates, but driver may still return error
|
||||
|
||||
@@ -49,6 +49,7 @@ var migrationsList = []Migration{
|
||||
{"agent_fields", migrations.MigrateAgentFields},
|
||||
{"mol_type_column", migrations.MigrateMolTypeColumn},
|
||||
{"hooked_status_migration", migrations.MigrateHookedStatus},
|
||||
{"event_fields", migrations.MigrateEventFields},
|
||||
}
|
||||
|
||||
// MigrationInfo contains metadata about a migration for inspection
|
||||
@@ -104,6 +105,8 @@ func getMigrationDescription(name string) string {
|
||||
"created_by_column": "Adds created_by column to track issue creator",
|
||||
"agent_fields": "Adds agent identity fields (hook_bead, role_bead, agent_state, etc.) for agent-as-bead pattern",
|
||||
"mol_type_column": "Adds mol_type column for molecule type classification (swarm/patrol/work)",
|
||||
"hooked_status_migration": "Migrates blocked hooked issues to in_progress status",
|
||||
"event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads",
|
||||
}
|
||||
|
||||
if desc, ok := descriptions[name]; ok {
|
||||
|
||||
50
internal/storage/sqlite/migrations/033_event_fields.go
Normal file
50
internal/storage/sqlite/migrations/033_event_fields.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MigrateEventFields adds event-specific fields to the issues table.
|
||||
// These fields support type: event beads for operational state changes.
|
||||
// Fields:
|
||||
// - event_kind: namespaced event type (e.g., patrol.muted, agent.started)
|
||||
// - actor: entity URI who caused this event
|
||||
// - target: entity URI or bead ID affected
|
||||
// - payload: event-specific JSON data
|
||||
func MigrateEventFields(db *sql.DB) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
def string
|
||||
}{
|
||||
{"event_kind", "TEXT DEFAULT ''"},
|
||||
{"actor", "TEXT DEFAULT ''"},
|
||||
{"target", "TEXT DEFAULT ''"},
|
||||
{"payload", "TEXT DEFAULT ''"},
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
// Check if column already exists
|
||||
var columnExists bool
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM pragma_table_info('issues')
|
||||
WHERE name = ?
|
||||
`, col.name).Scan(&columnExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check %s column: %w", col.name, err)
|
||||
}
|
||||
|
||||
if columnExists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add the column
|
||||
_, err = db.Exec(fmt.Sprintf(`ALTER TABLE issues ADD COLUMN %s %s`, col.name, col.def))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add %s column: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -503,9 +503,13 @@ func TestMigrateContentHashColumn(t *testing.T) {
|
||||
role_type TEXT DEFAULT '',
|
||||
rig TEXT DEFAULT '',
|
||||
mol_type TEXT DEFAULT '',
|
||||
event_kind TEXT DEFAULT '',
|
||||
actor TEXT DEFAULT '',
|
||||
target TEXT DEFAULT '',
|
||||
payload TEXT DEFAULT '',
|
||||
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
|
||||
);
|
||||
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '', '' FROM issues_backup;
|
||||
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '', '', '', '', '', '' FROM issues_backup;
|
||||
DROP TABLE issues_backup;
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
@@ -281,6 +281,11 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
var rig sql.NullString
|
||||
// Molecule type field
|
||||
var molType sql.NullString
|
||||
// Event fields
|
||||
var eventKind sql.NullString
|
||||
var actor sql.NullString
|
||||
var target sql.NullString
|
||||
var payload sql.NullString
|
||||
|
||||
var contentHash sql.NullString
|
||||
var compactedAtCommit sql.NullString
|
||||
@@ -292,7 +297,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template,
|
||||
await_type, await_id, timeout_ns, waiters,
|
||||
hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type
|
||||
hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type,
|
||||
event_kind, actor, target, payload
|
||||
FROM issues
|
||||
WHERE id = ?
|
||||
`, id).Scan(
|
||||
@@ -305,6 +311,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
&sender, &wisp, &pinned, &isTemplate,
|
||||
&awaitType, &awaitID, &timeoutNs, &waiters,
|
||||
&hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, &molType,
|
||||
&eventKind, &actor, &target, &payload,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -406,6 +413,19 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
|
||||
if molType.Valid {
|
||||
issue.MolType = types.MolType(molType.String)
|
||||
}
|
||||
// Event fields
|
||||
if eventKind.Valid {
|
||||
issue.EventKind = eventKind.String
|
||||
}
|
||||
if actor.Valid {
|
||||
issue.Actor = actor.String
|
||||
}
|
||||
if target.Valid {
|
||||
issue.Target = target.String
|
||||
}
|
||||
if payload.Valid {
|
||||
issue.Payload = payload.String
|
||||
}
|
||||
|
||||
// Fetch labels for this issue
|
||||
labels, err := s.GetLabels(ctx, issue.ID)
|
||||
|
||||
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS issues (
|
||||
is_template INTEGER DEFAULT 0,
|
||||
-- Molecule type field (bd-oxgi)
|
||||
mol_type TEXT DEFAULT '',
|
||||
-- Event fields (bd-ecmd)
|
||||
event_kind TEXT DEFAULT '',
|
||||
actor TEXT DEFAULT '',
|
||||
target TEXT DEFAULT '',
|
||||
payload TEXT DEFAULT '',
|
||||
-- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
|
||||
-- These relationships are now stored in the dependencies table
|
||||
-- closed_at constraint: closed issues must have it, tombstones may retain it from before deletion
|
||||
|
||||
@@ -100,6 +100,12 @@ type Issue struct {
|
||||
|
||||
// ===== Molecule Type Fields (swarm coordination) =====
|
||||
MolType MolType `json:"mol_type,omitempty"` // Molecule type: swarm|patrol|work (empty = work)
|
||||
|
||||
// ===== Event Fields (operational state changes) =====
|
||||
EventKind string `json:"event_kind,omitempty"` // Namespaced event type: patrol.muted, agent.started
|
||||
Actor string `json:"actor,omitempty"` // Entity URI who caused this event
|
||||
Target string `json:"target,omitempty"` // Entity URI or bead ID affected
|
||||
Payload string `json:"payload,omitempty"` // Event-specific JSON data
|
||||
}
|
||||
|
||||
// ComputeContentHash creates a deterministic hash of the issue's content.
|
||||
@@ -162,6 +168,12 @@ func (i *Issue) ComputeContentHash() string {
|
||||
// Molecule type
|
||||
w.str(string(i.MolType))
|
||||
|
||||
// Event fields
|
||||
w.str(i.EventKind)
|
||||
w.str(i.Actor)
|
||||
w.str(i.Target)
|
||||
w.str(i.Payload)
|
||||
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
@@ -395,12 +407,13 @@ const (
|
||||
TypeAgent IssueType = "agent" // Agent identity bead
|
||||
TypeRole IssueType = "role" // Agent role definition
|
||||
TypeConvoy IssueType = "convoy" // Cross-project tracking with reactive completion
|
||||
TypeEvent IssueType = "event" // Operational state change record
|
||||
)
|
||||
|
||||
// IsValid checks if the issue type value is valid
|
||||
func (t IssueType) IsValid() bool {
|
||||
switch t {
|
||||
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole, TypeConvoy:
|
||||
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole, TypeConvoy, TypeEvent:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user