feat(control): add app metadata and status contracts

This commit is contained in:
Vincent Koc 2026-05-01 15:23:03 -07:00
parent 0396564386
commit 1f001962a0
No known key found for this signature in database
4 changed files with 297 additions and 8 deletions

154
control/control.go Normal file
View File

@ -0,0 +1,154 @@
package control
import (
"os"
"strings"
"time"
)
const SchemaVersion = "crawlkit.control.v1"
type Manifest struct {
SchemaVersion string `json:"schema_version"`
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Binary Binary `json:"binary"`
Branding Branding `json:"branding"`
Paths Paths `json:"paths"`
Commands map[string]Command `json:"commands"`
Capabilities []string `json:"capabilities,omitempty"`
Privacy Privacy `json:"privacy"`
}
type Binary struct {
Name string `json:"name"`
}
type Branding struct {
SymbolName string `json:"symbol_name,omitempty"`
AccentColor string `json:"accent_color,omitempty"`
IconPath string `json:"icon_path,omitempty"`
BundleIdentifier string `json:"bundle_identifier,omitempty"`
}
type Paths struct {
DefaultConfig string `json:"default_config,omitempty"`
ConfigEnv string `json:"config_env,omitempty"`
DefaultDatabase string `json:"default_database,omitempty"`
DefaultCache string `json:"default_cache,omitempty"`
DefaultLogs string `json:"default_logs,omitempty"`
DefaultShare string `json:"default_share,omitempty"`
}
type Command struct {
Title string `json:"title,omitempty"`
Argv []string `json:"argv"`
JSON bool `json:"json,omitempty"`
Mutates bool `json:"mutates,omitempty"`
Legacy bool `json:"legacy,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
}
type Privacy struct {
ContainsPrivateMessages bool `json:"contains_private_messages"`
ExportsSecrets bool `json:"exports_secrets"`
LocalOnlyScopes []string `json:"local_only_scopes,omitempty"`
}
type Status struct {
SchemaVersion string `json:"schema_version"`
AppID string `json:"app_id"`
GeneratedAt string `json:"generated_at"`
State string `json:"state"`
Summary string `json:"summary"`
ConfigPath string `json:"config_path,omitempty"`
DatabasePath string `json:"database_path,omitempty"`
DatabaseBytes int64 `json:"database_bytes,omitempty"`
WALBytes int64 `json:"wal_bytes,omitempty"`
LastSyncAt string `json:"last_sync_at,omitempty"`
LastImportAt string `json:"last_import_at,omitempty"`
LastExportAt string `json:"last_export_at,omitempty"`
Counts []Count `json:"counts,omitempty"`
Freshness *Freshness `json:"freshness,omitempty"`
Share *Share `json:"share,omitempty"`
Databases []Database `json:"databases,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Errors []string `json:"errors,omitempty"`
}
type Count struct {
ID string `json:"id"`
Label string `json:"label"`
Value int64 `json:"value"`
}
type Freshness struct {
Status string `json:"status"`
AgeSeconds int64 `json:"age_seconds,omitempty"`
StaleAfterSeconds int64 `json:"stale_after_seconds,omitempty"`
}
type Share struct {
Enabled bool `json:"enabled"`
RepoPath string `json:"repo_path,omitempty"`
Remote string `json:"remote,omitempty"`
Branch string `json:"branch,omitempty"`
NeedsUpdate bool `json:"needs_update,omitempty"`
}
type Database struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
Role string `json:"role"`
Path string `json:"path"`
IsPrimary bool `json:"is_primary"`
Bytes int64 `json:"bytes"`
ModifiedAt string `json:"modified_at,omitempty"`
Counts []Count `json:"counts,omitempty"`
}
func NewManifest(id, displayName, binaryName string) Manifest {
return Manifest{
SchemaVersion: SchemaVersion,
ID: strings.TrimSpace(id),
DisplayName: strings.TrimSpace(displayName),
Binary: Binary{Name: strings.TrimSpace(binaryName)},
Commands: map[string]Command{},
}
}
func NewStatus(appID, summary string) Status {
return Status{
SchemaVersion: SchemaVersion,
AppID: strings.TrimSpace(appID),
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
State: "unknown",
Summary: strings.TrimSpace(summary),
}
}
func NewCount(id, label string, value int64) Count {
return Count{ID: strings.TrimSpace(id), Label: strings.TrimSpace(label), Value: value}
}
func SQLiteDatabase(id, label, role, path string, primary bool, counts []Count) Database {
db := Database{
ID: strings.TrimSpace(id),
Label: strings.TrimSpace(label),
Kind: "sqlite",
Role: strings.TrimSpace(role),
Path: strings.TrimSpace(path),
IsPrimary: primary,
Counts: append([]Count(nil), counts...),
}
if db.Role == "" {
db.Role = "archive"
}
if info, err := os.Stat(db.Path); err == nil {
db.Bytes = info.Size()
db.ModifiedAt = info.ModTime().UTC().Format(time.RFC3339)
}
return db
}

38
control/control_test.go Normal file
View File

@ -0,0 +1,38 @@
package control
import (
"os"
"path/filepath"
"testing"
)
func TestManifestDefaultsSchemaAndBinary(t *testing.T) {
manifest := NewManifest("slacrawl", "Slack Crawl", "slacrawl")
if manifest.SchemaVersion != SchemaVersion {
t.Fatalf("schema = %q", manifest.SchemaVersion)
}
if manifest.Binary.Name != "slacrawl" {
t.Fatalf("binary = %#v", manifest.Binary)
}
if manifest.Commands == nil {
t.Fatal("commands map should be initialized")
}
}
func TestSQLiteDatabaseStatsPathReadOnly(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "archive.db")
if err := os.WriteFile(path, []byte("sqlite"), 0o600); err != nil {
t.Fatal(err)
}
db := SQLiteDatabase("primary", "Primary archive", "archive", path, true, []Count{NewCount("messages", "Messages", 7)})
if db.Kind != "sqlite" || !db.IsPrimary || db.Bytes != 6 {
t.Fatalf("unexpected database: %#v", db)
}
if db.ModifiedAt == "" {
t.Fatal("modified_at should be set for existing paths")
}
if len(db.Counts) != 1 || db.Counts[0].Value != 7 {
t.Fatalf("counts = %#v", db.Counts)
}
}

View File

@ -21,13 +21,24 @@ type Item struct {
Subtitle string `json:"subtitle,omitempty"`
Detail string `json:"detail,omitempty"`
Tags []string `json:"tags,omitempty"`
Depth int `json:"depth,omitempty"`
}
type LayoutPreset string
const (
LayoutAuto LayoutPreset = ""
LayoutList LayoutPreset = "list"
LayoutChat LayoutPreset = "chat"
LayoutDocument LayoutPreset = "document"
)
type Row struct {
Source string `json:"source,omitempty"`
Kind string `json:"kind"`
ID string `json:"id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Depth int `json:"depth,omitempty"`
Scope string `json:"scope,omitempty"`
Container string `json:"container,omitempty"`
Author string `json:"author,omitempty"`
@ -54,6 +65,7 @@ type BrowseOptions struct {
EmptyMessage string
Rows []Row
JSON bool
Layout LayoutPreset
Stdin io.Reader
Stdout io.Writer
}
@ -68,8 +80,12 @@ func Browse(ctx context.Context, opts BrowseOptions) error {
return enc.Encode(opts.Rows)
}
items := make([]Item, 0, len(opts.Rows))
layout := opts.Layout
if layout == LayoutAuto {
layout = inferLayout(opts.Rows)
}
for _, row := range opts.Rows {
items = append(items, row.Item())
items = append(items, row.ItemForLayout(layout))
}
title := strings.TrimSpace(opts.Title)
if title == "" {
@ -103,6 +119,13 @@ func Browse(ctx context.Context, opts BrowseOptions) error {
}
func (r Row) Item() Item {
return r.ItemForLayout(LayoutAuto)
}
func (r Row) ItemForLayout(layout LayoutPreset) Item {
if layout == LayoutAuto {
layout = inferLayout([]Row{r})
}
title := firstNonEmpty(r.Title, r.Text, r.ID, "(untitled)")
tags := append([]string(nil), r.Tags...)
if r.Kind != "" {
@ -111,11 +134,16 @@ func (r Row) Item() Item {
if r.Source != "" {
tags = append([]string{r.Source}, tags...)
}
depth := r.Depth
if depth == 0 && layout == LayoutChat && strings.TrimSpace(r.ParentID) != "" {
depth = 1
}
return Item{
Title: title,
Subtitle: r.subtitle(),
Detail: r.detail(),
Subtitle: r.subtitleForLayout(layout),
Detail: r.detailForLayout(layout),
Tags: tags,
Depth: depth,
}
}
@ -154,8 +182,32 @@ func Run(ctx context.Context, opts Options) error {
return err
}
func (r Row) subtitle() string {
func inferLayout(rows []Row) LayoutPreset {
for _, row := range rows {
switch strings.ToLower(strings.TrimSpace(row.Kind)) {
case "message", "thread", "reply":
return LayoutChat
case "page", "database", "block", "collection":
return LayoutDocument
}
}
return LayoutList
}
func (r Row) subtitleForLayout(layout LayoutPreset) string {
if layout == LayoutChat {
parts := []string{r.Container, r.Author, r.CreatedAt, r.UpdatedAt}
return joinNonEmpty(parts, " ")
}
if layout == LayoutDocument {
parts := []string{r.Kind, r.Scope, r.Container, r.UpdatedAt, r.CreatedAt}
return joinNonEmpty(parts, " ")
}
parts := []string{r.Scope, r.Container, r.Author, r.CreatedAt, r.UpdatedAt}
return joinNonEmpty(parts, " ")
}
func joinNonEmpty(parts []string, sep string) string {
var out []string
for _, part := range parts {
part = strings.TrimSpace(part)
@ -163,26 +215,33 @@ func (r Row) subtitle() string {
out = append(out, part)
}
}
return strings.Join(out, " ")
return strings.Join(out, sep)
}
func (r Row) detail() string {
func (r Row) detailForLayout(layout LayoutPreset) string {
var lines []string
if text := strings.TrimSpace(r.Text); text != "" && text != strings.TrimSpace(r.Title) {
lines = append(lines, text)
}
if layout == LayoutDocument && strings.TrimSpace(r.URL) != "" {
lines = append(lines, fieldLine("url", r.URL))
}
for _, line := range []string{
fieldLine("id", r.ID),
fieldLine("parent", r.ParentID),
fieldLine("scope", r.Scope),
fieldLine("container", r.Container),
fieldLine("author", r.Author),
fieldLine("url", r.URL),
} {
if line != "" {
lines = append(lines, line)
}
}
if layout != LayoutDocument {
if line := fieldLine("url", r.URL); line != "" {
lines = append(lines, line)
}
}
if len(r.Fields) > 0 {
keys := make([]string, 0, len(r.Fields))
for key := range r.Fields {
@ -351,6 +410,9 @@ func (m model) View() string {
b.WriteString(" ")
}
line := item.Title
if item.Depth > 0 {
line = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + line
}
if item.Subtitle != "" {
line += " " + ansiDim + item.Subtitle + ansiReset
if selected {

View File

@ -45,7 +45,7 @@ func TestRowItemUsesSharedArchiveShape(t *testing.T) {
Title: "panic locked database",
Text: "full message text",
Fields: map[string]string{"reply_to": "m0"},
}.Item()
}.ItemForLayout(LayoutList)
if item.Title != "panic locked database" {
t.Fatalf("title = %q", item.Title)
}
@ -60,6 +60,41 @@ func TestRowItemUsesSharedArchiveShape(t *testing.T) {
}
}
func TestChatLayoutIndentsReplyRows(t *testing.T) {
item := Row{
Source: "discord",
Kind: "message",
ID: "m2",
ParentID: "m1",
Container: "general",
Author: "sam",
Title: "reply",
}.ItemForLayout(LayoutChat)
if item.Depth != 1 {
t.Fatalf("depth = %d", item.Depth)
}
if strings.Contains(item.Subtitle, "discord") {
t.Fatalf("chat subtitle should prioritize chat context, got %q", item.Subtitle)
}
}
func TestDocumentLayoutPrioritizesURLDetail(t *testing.T) {
item := Row{
Source: "notion",
Kind: "page",
ID: "page1",
Title: "Launch plan",
URL: "https://example.com/launch",
UpdatedAt: "2026-05-01T12:00:00Z",
}.ItemForLayout(LayoutDocument)
if !strings.HasPrefix(item.Detail, "url=https://example.com/launch") {
t.Fatalf("detail = %q", item.Detail)
}
if !strings.Contains(item.Subtitle, "page") || !strings.Contains(item.Subtitle, "2026-05-01") {
t.Fatalf("subtitle = %q", item.Subtitle)
}
}
func TestModelFilterAndRender(t *testing.T) {
m := newModel(Options{
Title: "notcrawl",