refactor: the task catalog is removed

Same situation as the session catalog: nothing read it. Its only importer was
the CLI command that rebuilt it, and no product path ever opened the file that
command wrote. Task snapshots and event logs were already the authoritative
data and are read directly.

That leaves two projections, both with real readers and both doing something a
directory scan cannot: history-search maintains an inverted index for BM25,
usage-catalog a precomputed rollup.
This commit is contained in:
esengine committed 2026-08-21 20:57:57 -07:00
1 parent 404c478814
commit 8c29942890
18 files changed
+2 -1584

No files matched your search

-8
View File
@@ -482,14 +482,6 @@ See [Usage Catalog](./USAGE_CATALOG.md).
Inspect or rebuild the disposable task projection independently:
```sh
reasonix doctor catalogs [--json]
reasonix catalogs reindex tasks [--project PATH ...] [--json]
```
See [Task Catalog](./TASK_CATALOG.md) for the authoritative FileStore boundary,
cross-project routing, and rebuild behavior.
### Memory diagnostics and recovery
Bare `/memory` shows all active project/global facts without hiding same-name
-10
View File
@@ -417,16 +417,6 @@ reasonix catalogs reindex history [--dir PATH ...] [--json]
reasonix catalogs reindex usage [--json]
详见 [用量 Catalog](./USAGE_CATALOG.zh-CN.md)。
可以独立检查或重建可丢弃的 Task 投影:
```sh
reasonix doctor catalogs [--json]
reasonix catalogs reindex tasks [--project PATH ...] [--json]
```
权威 FileStore 边界、跨项目路由和重建行为见
[Task Catalog](./TASK_CATALOG.zh-CN.md)。
### 记忆诊断与恢复
直接运行 `/memory` 会显示全部 project/global active facts,不会隐藏跨 scope 的同名条目。
+1 -2
View File
@@ -54,8 +54,7 @@ nothing else.
The history projection is documented in
[History Search Catalog](./HISTORY_SEARCH_CATALOG.md).
The usage rollup projection is documented in [Usage Catalog](./USAGE_CATALOG.md).
Task snapshots and event logs likewise remain authoritative; the rebuildable
cross-project projection is documented in [Task Catalog](./TASK_CATALOG.md).
Task snapshots and event logs are likewise authoritative.
The global user config is named `config.toml`. Project-local config files keep
the name `reasonix.toml`. If someone says "global reasonix.toml", they usually
+1 -2
View File
@@ -41,8 +41,7 @@ Legacy 迁移、OS home 约定目录扫描以及其他 fallback 路径都会跳
JSONL、event log、metadata sidecar 和 `desktop-projects.json` 是权威的会话数据,
列出或打开一个会话不读其它任何东西。Task snapshot 和
event log 也仍是权威数据;可重建的跨项目投影见
[Task Catalog](./TASK_CATALOG.zh-CN.md)。
event log 也是权威数据。
全局用户配置文件名是 `config.toml`。项目本地配置文件仍叫 `reasonix.toml`。
如果有人说“全局 reasonix.toml”,通常指的是 `<Reasonix home>/config.toml`。
-26
View File
@@ -1,26 +0,0 @@
# Task Catalog
Reasonix stores the cross-project task projection at
`<cache root>/task-catalog/v1.sqlite`. Task snapshots, event JSONL, idempotency
files, file locks, version CAS, and leases remain authoritative. SQLite is never
used to accept stop, cancel, requeue, or open-session commands.
An observed `FileStore` sends non-blocking snapshot and event hints only after
the per-task lock is released. Snapshots are indexed eagerly in bounded batches;
event logs are indexed lazily when a task expands, an event query arrives, or a
new event is appended. Byte-offset and sequence checkpoints resume after an
interruption. Corrupt snapshots and event lines degrade only that task.
The Desktop task center pages across current session, project, or all registered
projects. Project keys are full SHA-256 hashes of canonical saved roots and are
resolved before every action. The control service then re-reads the authoritative
snapshot. Current process jobs and controller state always overlay catalog data,
and expired leases are reconciled at read time without rewriting snapshots.
```sh
reasonix doctor catalogs [--json]
reasonix catalogs reindex tasks [--project PATH ...] [--json]
```
Catalog corruption or partial indexing does not disable task control. Reindexing
replaces only the disposable projection.
-21
View File
@@ -1,21 +0,0 @@
# Task Catalog
Reasonix 将跨项目任务投影保存到 `<cache root>/task-catalog/v1.sqlite`。任务 snapshot、
event JSONL、idempotency 文件、文件锁、version CAS 和 lease 继续作为权威数据。SQLite
绝不参与 stop、cancel、requeue 或 open-session 的接受判断。
Observed `FileStore` 只在 per-task lock 释放后发送非阻塞 snapshot/event 提示。Snapshot
按有界批次优先索引;event log 仅在任务展开、事件查询或追加新事件时惰性索引。byte
offset 和 sequence checkpoint 可在强退后续传;坏 snapshot 或 event line 只降级单个任务。
Desktop Task Center 对当前会话、当前项目或全部已注册项目分页。Project key 是 canonical
已保存 root 的完整 SHA-256,每次 action 都先解析 key,再由 control service 重读权威
snapshot。当前进程的 jobs/controller 状态始终覆盖 catalog;过期 lease 只在读取时对账,
不回写 snapshot。
```sh
reasonix doctor catalogs [--json]
reasonix catalogs reindex tasks [--project PATH ...] [--json]
```
Catalog 损坏或未完成索引不会禁用任务控制;reindex 只替换可丢弃投影。
-49
View File
@@ -1,49 +0,0 @@
package cli
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/taskcatalog"
)
func init() {
registerCatalogCommand(catalogCommand{
name: "tasks", path: taskcatalog.DefaultPath, reindex: reindexTaskCatalog,
completionFlags: []cliCompletionFlag{
completionFlag("--json", cliCompletionNoValue),
completionFlag("--project", cliCompletionPathValue),
},
})
}
func reindexTaskCatalog(args []string) int {
fs := flag.NewFlagSet("catalogs reindex tasks", flag.ContinueOnError)
var projects stringListFlag
jsonOut := fs.Bool("json", false, "print status as JSON")
fs.Var(&projects, "project", "project root to index; repeat for multiple projects")
if code, ok := parseCommandFlags(fs, args); !ok {
return code
}
if len(projects) == 0 {
for _, target := range defaultSessionCatalogTargets() {
if strings.TrimSpace(target.WorkspaceRoot) != "" {
projects = append(projects, target.WorkspaceRoot)
}
}
}
items := make([]taskcatalog.Project, 0, len(projects))
for _, root := range projects {
items = append(items, taskcatalog.Project{Root: root, Label: filepath.Base(root)})
}
status, err := taskcatalog.Rebuild(context.Background(), "", items)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return printCatalogStatus(status, *jsonOut)
}
-50
View File
@@ -1,50 +0,0 @@
package taskcatalog
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"testing"
"time"
"reasonix/internal/taskmonitor"
)
func BenchmarkWarmFirstPageHundredThousandTasks(b *testing.B) {
catalog, err := Open(context.Background(), filepath.Join(b.TempDir(), "tasks.sqlite"))
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = catalog.Close(context.Background()) })
root := b.TempDir()
project := Project{Key: ProjectKey(root), Root: root, Label: "Benchmark"}
if _, err := catalog.db.Exec(`INSERT INTO task_projects(project_key,project_root,project_label,state) VALUES(?,?,?,'ready')`,
project.Key, project.Root, project.Label); err != nil {
b.Fatal(err)
}
tx, err := catalog.db.Begin()
if err != nil {
b.Fatal(err)
}
now := time.Now().UTC()
for i := range 100_000 {
task := taskmonitor.TaskSnapshot{SchemaVersion: 1, TaskID: fmt.Sprintf("task-%06d", i), SessionID: "session",
State: taskmonitor.TaskStateSucceeded, Version: 1, CreatedAt: now, UpdatedAt: now.Add(time.Duration(i) * time.Millisecond)}
raw, _ := json.Marshal(task)
if _, err := tx.Exec(`INSERT INTO task_snapshots(project_key,task_id,session_id,state,version,created_at,updated_at,snapshot_json)
VALUES(?,?,?,?,?,?,?,?)`, project.Key, task.TaskID, task.SessionID, task.State, task.Version,
task.CreatedAt.UnixMilli(), task.UpdatedAt.UnixMilli(), raw); err != nil {
b.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for range b.N {
if _, err := catalog.ListPage(context.Background(), PageRequest{ProjectKeys: []string{project.Key}, Limit: 50}); err != nil {
b.Fatal(err)
}
}
}
-710
View File
@@ -1,710 +0,0 @@
// Package taskcatalog maintains a disposable cross-project projection of the
// authoritative taskmonitor FileStore.
package taskcatalog
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"reasonix/internal/config"
"reasonix/internal/projectiondb"
"reasonix/internal/taskmonitor"
)
const (
SchemaVersion = 1
DefaultLimit = 50
MaxLimit = 200
missingGrace = 30 * time.Second
)
type Status struct {
State string `json:"state"`
Mode projectiondb.Mode `json:"mode"`
Path string `json:"path,omitempty"`
Revision uint64 `json:"revision"`
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
Pending int64 `json:"pending"`
Failed int64 `json:"failed"`
LastError string `json:"lastError,omitempty"`
}
type Project struct {
Key string `json:"projectKey"`
Root string `json:"projectRoot"`
Label string `json:"projectLabel"`
}
type PageRequest struct {
ProjectKeys []string
SessionID string
States []string
Query string
Cursor string
Limit int
}
type Item struct {
ProjectKey string `json:"projectKey"`
ProjectLabel string `json:"projectLabel"`
Task taskmonitor.TaskSnapshot `json:"task"`
}
type Page struct {
Items []Item `json:"items"`
NextCursor string `json:"nextCursor"`
Revision uint64 `json:"revision"`
Partial bool `json:"partial"`
StaleCursor bool `json:"staleCursor"`
Status Status `json:"status"`
}
type EventPage struct {
Items []taskmonitor.TaskEvent `json:"items"`
NextSequence int `json:"nextSequence"`
Partial bool `json:"partial"`
}
type cursor struct {
Revision uint64 `json:"r"`
Updated int64 `json:"u"`
Project string `json:"p"`
Task string `json:"t"`
}
type request struct {
projectRoot string
taskID string
events bool
flush chan struct{}
}
type Catalog struct {
db *sql.DB
store *taskmonitor.FileStore
ctx context.Context
cancel context.CancelFunc
queue chan request
dirtyWake chan struct{}
dirtyProjects sync.Map
wg sync.WaitGroup
closing atomic.Bool
revision atomic.Uint64
statusMu sync.RWMutex
status Status
projectLocks sync.Map
reconcileMu sync.Mutex
reconciling map[string]bool
registered map[string]bool
reconcileDone bool
closeOnce sync.Once
closeDone chan struct{}
closeErr error
}
// DefaultPath returns the disposable task projection path under CacheDir.
// Empty when cache is unavailable so Open falls back to an in-memory projection.
func DefaultPath() string {
cache := strings.TrimSpace(config.CacheDir())
if cache == "" {
return ""
}
return filepath.Join(cache, "task-catalog", "v1.sqlite")
}
func ProjectKey(root string) string {
root = filepath.Clean(strings.TrimSpace(root))
if abs, err := filepath.Abs(root); err == nil {
root = abs
}
sum := sha256.Sum256([]byte(root))
return hex.EncodeToString(sum[:])
}
const schema = `
CREATE TABLE task_state(id INTEGER PRIMARY KEY CHECK(id=1),revision INTEGER NOT NULL DEFAULT 0);
INSERT INTO task_state(id,revision) VALUES(1,0);
CREATE TABLE task_projects(project_key TEXT PRIMARY KEY,project_root TEXT UNIQUE NOT NULL,project_label TEXT NOT NULL DEFAULT '',
signature TEXT NOT NULL DEFAULT '',scan_generation INTEGER NOT NULL DEFAULT 0,scan_cursor TEXT NOT NULL DEFAULT '',state TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',indexed INTEGER NOT NULL DEFAULT 0,total INTEGER NOT NULL DEFAULT 0,completed_at INTEGER NOT NULL DEFAULT 0);
CREATE TABLE task_snapshots(project_key TEXT NOT NULL,task_id TEXT NOT NULL,session_id TEXT NOT NULL DEFAULT '',job_id TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT '',label TEXT NOT NULL DEFAULT '',state TEXT NOT NULL,runtime_state TEXT NOT NULL DEFAULT '',runtime_lease_until INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,error_code TEXT NOT NULL DEFAULT '',snapshot_fingerprint TEXT NOT NULL DEFAULT '',
snapshot_json BLOB NOT NULL,health TEXT NOT NULL DEFAULT 'ok',missing_since INTEGER NOT NULL DEFAULT 0,seen_generation INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(project_key,task_id),FOREIGN KEY(project_key) REFERENCES task_projects(project_key) ON DELETE CASCADE);
CREATE TABLE task_event_sources(project_key TEXT NOT NULL,task_id TEXT NOT NULL,path TEXT NOT NULL,size INTEGER NOT NULL DEFAULT 0,indexed_offset INTEGER NOT NULL DEFAULT 0,
fingerprint TEXT NOT NULL DEFAULT '',state TEXT NOT NULL DEFAULT 'pending',error TEXT NOT NULL DEFAULT '',PRIMARY KEY(project_key,task_id));
CREATE TABLE task_events(project_key TEXT NOT NULL,task_id TEXT NOT NULL,sequence INTEGER NOT NULL,timestamp INTEGER NOT NULL,event_type TEXT NOT NULL,
state TEXT NOT NULL,runtime_state TEXT NOT NULL DEFAULT '',event_json BLOB NOT NULL,PRIMARY KEY(project_key,task_id,sequence));
CREATE INDEX idx_task_project_page ON task_snapshots(project_key,updated_at DESC,task_id);
CREATE INDEX idx_task_state_page ON task_snapshots(state,updated_at DESC,project_key,task_id);
CREATE INDEX idx_task_session ON task_snapshots(session_id,updated_at DESC,task_id);
CREATE INDEX idx_task_events_page ON task_events(project_key,task_id,sequence);
`
func migrations() []projectiondb.Migration {
return []projectiondb.Migration{{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, schema)
return err
}}}
}
func Open(ctx context.Context, path string) (*Catalog, error) {
if path == "" {
path = DefaultPath()
}
inMemory := strings.TrimSpace(path) == ""
if inMemory {
path = ""
}
handle, err := projectiondb.Open(ctx, projectiondb.OpenOptions{
Path: path, MemoryName: "task-catalog", Migrations: migrations(), InMemory: inMemory, MaxOpenConns: 4,
})
if err != nil {
return nil, err
}
workerCtx, cancel := context.WithCancel(context.Background())
c := &Catalog{db: handle.DB, store: taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")), ctx: workerCtx, cancel: cancel,
queue: make(chan request, 1024), dirtyWake: make(chan struct{}, 1), reconciling: map[string]bool{}, registered: map[string]bool{}, closeDone: make(chan struct{}),
status: Status{State: string(handle.Status.State), Mode: handle.Status.Mode, Path: handle.Status.Path, LastError: handle.Status.LastError}}
var revision uint64
_ = c.db.QueryRowContext(ctx, `SELECT revision FROM task_state WHERE id=1`).Scan(&revision)
c.revision.Store(revision)
c.refresh(ctx)
c.wg.Add(1)
go c.worker()
return c, nil
}
func (c *Catalog) ObservedStore() *taskmonitor.FileStore {
return taskmonitor.NewObservedFileStore(filepath.Join(".reasonix", "tasks"), c)
}
func (c *Catalog) SnapshotChanged(projectRoot, taskID string) {
c.enqueue(request{projectRoot: projectRoot, taskID: taskID})
}
func (c *Catalog) EventsChanged(projectRoot, taskID string) {
c.enqueue(request{projectRoot: projectRoot, taskID: taskID, events: true})
}
func (c *Catalog) enqueue(req request) {
if c.closing.Load() {
return
}
select {
case c.queue <- req:
default:
c.dirtyProjects.Store(req.projectRoot, true)
c.wakeDirty()
}
}
func (c *Catalog) worker() {
defer c.wg.Done()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
if root, ok := c.takeDirtyProject(); ok {
if project, exists, err := c.projectByRoot(c.ctx, root); err == nil && exists {
_ = c.ReconcileProject(c.ctx, project)
} else if err == nil {
_, _ = c.RegisterProject(c.ctx, root, filepath.Base(root))
}
continue
}
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.markRegisteredProjectsDirty()
case <-c.dirtyWake:
case req := <-c.queue:
if req.flush != nil {
close(req.flush)
continue
}
if req.events {
_ = c.indexEvents(c.ctx, req.projectRoot, req.taskID)
} else {
_ = c.indexSnapshot(c.ctx, req.projectRoot, req.taskID, 0)
}
}
}
}
func (c *Catalog) takeDirtyProject() (string, bool) {
var root string
c.dirtyProjects.Range(func(key, _ any) bool {
root, _ = key.(string)
c.dirtyProjects.Delete(key)
return false
})
return root, root != ""
}
func (c *Catalog) markRegisteredProjectsDirty() {
rows, err := c.db.QueryContext(c.ctx, `SELECT project_root FROM task_projects`)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var root string
if rows.Scan(&root) == nil {
c.dirtyProjects.Store(root, true)
}
}
}
func (c *Catalog) wakeDirty() {
select {
case c.dirtyWake <- struct{}{}:
default:
}
}
func (c *Catalog) Flush(ctx context.Context) error {
done := make(chan struct{})
select {
case c.queue <- request{flush: done}:
case <-ctx.Done():
return ctx.Err()
}
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *Catalog) RegisterProject(ctx context.Context, root, label string) (Project, error) {
project := normalizeProject(Project{Root: root, Label: label})
_, err := c.db.ExecContext(ctx, `INSERT INTO task_projects(project_key,project_root,project_label,state) VALUES(?,?,?,'pending')
ON CONFLICT(project_key) DO UPDATE SET project_root=excluded.project_root,project_label=excluded.project_label`, project.Key, project.Root, project.Label)
c.reconcileMu.Lock()
firstRegistration := !c.registered[project.Key]
c.registered[project.Key] = true
c.reconcileMu.Unlock()
if err == nil && firstRegistration {
c.scheduleReconcile(project)
}
return project, err
}
// RequestReconcileProject forces a background authority scan even when the
// project was already registered by an earlier page request.
func (c *Catalog) RequestReconcileProject(ctx context.Context, root, label string) error {
project, err := c.RegisterProject(ctx, root, label)
if err != nil {
return err
}
c.scheduleReconcile(project)
return nil
}
func (c *Catalog) scheduleReconcile(project Project) {
c.reconcileMu.Lock()
if c.reconcileDone || c.reconciling[project.Key] {
c.reconcileMu.Unlock()
return
}
c.reconciling[project.Key] = true
c.wg.Add(1)
c.reconcileMu.Unlock()
go func() {
defer func() {
c.reconcileMu.Lock()
delete(c.reconciling, project.Key)
c.reconcileMu.Unlock()
c.wg.Done()
}()
_ = c.ReconcileProject(c.ctx, project)
}()
}
func (c *Catalog) ReconcileProject(ctx context.Context, project Project) error {
project = normalizeProject(project)
unlock := c.lockProject(project.Key)
defer unlock()
tasks, err := c.store.ListTasks(ctx, project.Root)
if err != nil {
return err
}
var generation int64
err = c.db.QueryRowContext(ctx, `UPDATE task_projects SET scan_generation=scan_generation+1,state='scanning',total=? WHERE project_key=? RETURNING scan_generation`, len(tasks), project.Key).Scan(&generation)
if err != nil {
return err
}
for _, task := range tasks {
if err := c.upsertSnapshot(ctx, project, task, generation); err != nil {
continue
}
}
now := time.Now().UnixMilli()
_, _ = c.db.ExecContext(ctx, `UPDATE task_snapshots SET missing_since=CASE WHEN missing_since=0 THEN ? ELSE missing_since END,health='missing'
WHERE project_key=? AND seen_generation<>?`, now, project.Key, generation)
cutoff := now - missingGrace.Milliseconds()
_, _ = c.db.ExecContext(ctx, `DELETE FROM task_events WHERE project_key=? AND task_id IN (
SELECT task_id FROM task_snapshots WHERE project_key=? AND seen_generation<>? AND missing_since>0 AND missing_since<=?
)`, project.Key, project.Key, generation, cutoff)
_, _ = c.db.ExecContext(ctx, `DELETE FROM task_event_sources WHERE project_key=? AND task_id IN (
SELECT task_id FROM task_snapshots WHERE project_key=? AND seen_generation<>? AND missing_since>0 AND missing_since<=?
)`, project.Key, project.Key, generation, cutoff)
_, _ = c.db.ExecContext(ctx, `DELETE FROM task_snapshots WHERE project_key=? AND seen_generation<>? AND missing_since>0 AND missing_since<=?`,
project.Key, generation, cutoff)
tx, beginErr := c.db.BeginTx(ctx, nil)
if beginErr != nil {
return beginErr
}
if _, err = tx.ExecContext(ctx, `UPDATE task_projects SET state='ready',indexed=?,completed_at=? WHERE project_key=?`, len(tasks), now, project.Key); err != nil {
_ = tx.Rollback()
return err
}
revision, err := bump(ctx, tx)
if err != nil {
_ = tx.Rollback()
return err
}
if err = tx.Commit(); err != nil {
return err
}
c.revision.Store(revision)
c.refresh(context.Background())
return nil
}
func (c *Catalog) indexSnapshot(ctx context.Context, root, taskID string, generation int64) error {
project, ok, err := c.projectByRoot(ctx, root)
if err != nil {
return err
}
if !ok {
project, err = c.RegisterProject(ctx, root, filepath.Base(root))
if err != nil {
return err
}
}
unlock := c.lockProject(project.Key)
defer unlock()
task, err := c.store.GetTask(ctx, project.Root, taskID)
if err != nil || task == nil {
return err
}
return c.upsertSnapshot(ctx, project, *task, generation)
}
func (c *Catalog) upsertSnapshot(ctx context.Context, project Project, task taskmonitor.TaskSnapshot, generation int64) error {
b, err := json.Marshal(task)
if err != nil {
return err
}
hash := sha256.Sum256(b)
lease := int64(0)
if !task.RuntimeLeaseUntil.IsZero() {
lease = task.RuntimeLeaseUntil.UnixMilli()
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO task_snapshots(project_key,task_id,session_id,job_id,state,runtime_state,runtime_lease_until,version,
created_at,updated_at,error_code,snapshot_fingerprint,snapshot_json,health,missing_since,seen_generation) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,'ok',0,?)
ON CONFLICT(project_key,task_id) DO UPDATE SET session_id=excluded.session_id,job_id=excluded.job_id,state=excluded.state,
runtime_state=excluded.runtime_state,runtime_lease_until=excluded.runtime_lease_until,version=excluded.version,created_at=excluded.created_at,
updated_at=excluded.updated_at,error_code=excluded.error_code,snapshot_fingerprint=excluded.snapshot_fingerprint,snapshot_json=excluded.snapshot_json,
health='ok',missing_since=0,seen_generation=excluded.seen_generation`, project.Key, task.TaskID, task.SessionID, task.JobID, task.State,
task.RuntimeState, lease, task.Version, task.CreatedAt.UnixMilli(), task.UpdatedAt.UnixMilli(), task.ErrorCode, hex.EncodeToString(hash[:]), b, generation)
if err != nil {
_ = tx.Rollback()
return err
}
revision, err := bump(ctx, tx)
if err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
c.revision.Store(revision)
c.refresh(context.Background())
return nil
}
func (c *Catalog) indexEvents(ctx context.Context, root, taskID string) error {
if taskID == "" || filepath.Base(taskID) != taskID || strings.ContainsAny(taskID, `/\\`) {
return errors.New("invalid task id")
}
project, ok, err := c.projectByRoot(ctx, root)
if err != nil || !ok {
return err
}
unlock := c.lockProject(project.Key)
defer unlock()
path := filepath.Join(project.Root, ".reasonix", "tasks", taskID, "events.jsonl")
info, statErr := os.Stat(path)
if errors.Is(statErr, os.ErrNotExist) {
return nil
}
if statErr != nil {
return statErr
}
fingerprint := fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano())
var oldPath, oldFingerprint string
var oldSize, offset int64
err = c.db.QueryRowContext(ctx, `SELECT path,size,indexed_offset,fingerprint FROM task_event_sources WHERE project_key=? AND task_id=?`,
project.Key, taskID).Scan(&oldPath, &oldSize, &offset, &oldFingerprint)
reset := errors.Is(err, sql.ErrNoRows) || oldPath != path || info.Size() < oldSize || info.Size() == oldSize && fingerprint != oldFingerprint
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if !reset && info.Size() == oldSize && fingerprint == oldFingerprint {
return nil
}
if reset {
offset = 0
}
tail, err := c.store.ReadEventTail(ctx, project.Root, taskID, offset)
if err != nil {
return err
}
reset = reset || tail.Reset
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if reset {
if _, err := tx.ExecContext(ctx, `DELETE FROM task_events WHERE project_key=? AND task_id=?`, project.Key, taskID); err != nil {
_ = tx.Rollback()
return err
}
}
for _, event := range tail.Items {
b, _ := json.Marshal(event)
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO task_events(project_key,task_id,sequence,timestamp,event_type,state,runtime_state,event_json)
VALUES(?,?,?,?,?,?,?,?)`, project.Key, taskID, event.Sequence, event.Timestamp.UnixMilli(), event.EventType, event.State, event.RuntimeState, b); err != nil {
_ = tx.Rollback()
return err
}
}
_, err = tx.ExecContext(ctx, `INSERT INTO task_event_sources(project_key,task_id,path,size,indexed_offset,fingerprint,state,error)
VALUES(?,?,?,?,?,?,'ready','') ON CONFLICT(project_key,task_id) DO UPDATE SET path=excluded.path,size=excluded.size,
indexed_offset=excluded.indexed_offset,fingerprint=excluded.fingerprint,state='ready',error=''`, project.Key, taskID, path,
info.Size(), tail.NextOffset, fingerprint)
if err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
func (c *Catalog) ListPage(ctx context.Context, req PageRequest) (Page, error) {
status := c.Status()
out := Page{Items: []Item{}, Revision: c.revision.Load(), Partial: status.Pending > 0, Status: status}
limit := req.Limit
if limit <= 0 {
limit = DefaultLimit
}
if limit > MaxLimit {
limit = MaxLimit
}
cur, err := decodeCursor(req.Cursor)
if err != nil {
return out, err
}
if cur != nil && cur.Revision != out.Revision {
out.StaleCursor = true
return out, nil
}
where := []string{`s.health='ok'`, `s.missing_since=0`}
args := []any{}
if len(req.ProjectKeys) > 0 {
parts := make([]string, len(req.ProjectKeys))
for i, key := range req.ProjectKeys {
parts[i] = "?"
args = append(args, key)
}
where = append(where, `s.project_key IN (`+strings.Join(parts, ",")+`)`)
}
if req.SessionID != "" {
where = append(where, `s.session_id=?`)
args = append(args, req.SessionID)
}
if len(req.States) > 0 {
parts := make([]string, len(req.States))
for i, state := range req.States {
parts[i] = "?"
args = append(args, state)
}
where = append(where, `s.state IN (`+strings.Join(parts, ",")+`)`)
}
if query := strings.ToLower(strings.TrimSpace(req.Query)); query != "" {
where = append(where, `(lower(s.task_id) LIKE ? OR lower(s.session_id) LIKE ? OR lower(s.error_code) LIKE ?)`)
like := "%" + query + "%"
args = append(args, like, like, like)
}
if cur != nil {
where = append(where, `(s.updated_at<? OR (s.updated_at=? AND s.project_key>?) OR (s.updated_at=? AND s.project_key=? AND s.task_id>?))`)
args = append(args, cur.Updated, cur.Updated, cur.Project, cur.Updated, cur.Project, cur.Task)
}
args = append(args, limit+1)
rows, err := c.db.QueryContext(ctx, `SELECT s.project_key,p.project_label,s.snapshot_json FROM task_snapshots s JOIN task_projects p ON p.project_key=s.project_key
WHERE `+strings.Join(where, ` AND `)+` ORDER BY s.updated_at DESC,s.project_key,s.task_id LIMIT ?`, args...)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var item Item
var raw []byte
if err := rows.Scan(&item.ProjectKey, &item.ProjectLabel, &raw); err != nil {
return out, err
}
if json.Unmarshal(raw, &item.Task) != nil {
continue
}
item.Task.ReconcileRuntime(time.Now())
out.Items = append(out.Items, item)
}
if len(out.Items) > limit {
out.Items = out.Items[:limit]
last := out.Items[len(out.Items)-1]
out.NextCursor = encodeCursor(cursor{Revision: out.Revision, Updated: last.Task.UpdatedAt.UnixMilli(), Project: last.ProjectKey, Task: last.Task.TaskID})
}
return out, rows.Err()
}
func (c *Catalog) ListEventPage(ctx context.Context, projectKey, taskID string, after, limit int) (EventPage, error) {
out := EventPage{Items: []taskmonitor.TaskEvent{}, NextSequence: after}
project, ok, err := c.Project(ctx, projectKey)
if err != nil || !ok {
return out, err
}
if err := c.indexEvents(ctx, project.Root, taskID); err != nil {
out.Partial = true
return out, nil
}
if limit <= 0 {
limit = DefaultLimit
}
if limit > MaxLimit {
limit = MaxLimit
}
rows, err := c.db.QueryContext(ctx, `SELECT event_json FROM task_events WHERE project_key=? AND task_id=? AND sequence>? ORDER BY sequence LIMIT ?`, projectKey, taskID, after, limit)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var raw []byte
var event taskmonitor.TaskEvent
if err := rows.Scan(&raw); err != nil {
return out, err
}
if json.Unmarshal(raw, &event) == nil {
out.Items = append(out.Items, event)
out.NextSequence = event.Sequence
}
}
return out, rows.Err()
}
func (c *Catalog) Project(ctx context.Context, key string) (Project, bool, error) {
var project Project
err := c.db.QueryRowContext(ctx, `SELECT project_key,project_root,project_label FROM task_projects WHERE project_key=?`, key).Scan(&project.Key, &project.Root, &project.Label)
if errors.Is(err, sql.ErrNoRows) {
return project, false, nil
}
return project, err == nil, err
}
func (c *Catalog) projectByRoot(ctx context.Context, root string) (Project, bool, error) {
return c.Project(ctx, ProjectKey(root))
}
func bump(ctx context.Context, tx *sql.Tx) (uint64, error) {
if _, err := tx.ExecContext(ctx, `UPDATE task_state SET revision=revision+1 WHERE id=1`); err != nil {
return 0, err
}
var revision uint64
err := tx.QueryRowContext(ctx, `SELECT revision FROM task_state WHERE id=1`).Scan(&revision)
return revision, err
}
func encodeCursor(value cursor) string {
b, _ := json.Marshal(value)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeCursor(value string) (*cursor, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
b, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return nil, fmt.Errorf("invalid task cursor: %w", err)
}
var out cursor
if json.Unmarshal(b, &out) != nil || out.Task == "" {
return nil, errors.New("invalid task cursor")
}
return &out, nil
}
func (c *Catalog) refresh(ctx context.Context) {
var indexed, total, pending, failed int64
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_snapshots WHERE health='ok'`).Scan(&indexed)
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(total),0) FROM task_projects`).Scan(&total)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_projects WHERE state<>'ready'`).Scan(&pending)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_snapshots WHERE health='corrupt'`).Scan(&failed)
c.statusMu.Lock()
c.status.Revision, c.status.Indexed, c.status.Total, c.status.Pending, c.status.Failed = c.revision.Load(), indexed, total, pending, failed
c.statusMu.Unlock()
}
func (c *Catalog) Status() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Catalog) Close(ctx context.Context) error {
if c == nil {
return nil
}
c.closeOnce.Do(func() {
c.closing.Store(true)
c.cancel()
c.reconcileMu.Lock()
c.reconcileDone = true
c.reconcileMu.Unlock()
go func() {
c.wg.Wait()
c.closeErr = c.db.Close()
close(c.closeDone)
}()
})
select {
case <-c.closeDone:
return c.closeErr
case <-ctx.Done():
return ctx.Err()
}
}
-97
View File
@@ -1,97 +0,0 @@
package taskcatalog
import (
"context"
"path/filepath"
"testing"
"time"
"reasonix/internal/taskmonitor"
)
func snapshot(id, session string, version uint64, updated time.Time) taskmonitor.TaskSnapshot {
return taskmonitor.TaskSnapshot{SchemaVersion: 1, TaskID: id, SessionID: session, State: taskmonitor.TaskStateRunning,
RuntimeState: taskmonitor.RuntimeStateAlive, RuntimeLeaseUntil: updated.Add(time.Hour), Version: version,
CreatedAt: updated.Add(-time.Minute), UpdatedAt: updated}
}
func TestObservedStoreIndexesSnapshotsAndEvents(t *testing.T) {
t.Parallel()
ctx := context.Background()
projectRoot := t.TempDir()
catalog, err := Open(ctx, filepath.Join(t.TempDir(), "tasks.sqlite"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
project, err := catalog.RegisterProject(ctx, projectRoot, "Demo")
if err != nil {
t.Fatal(err)
}
store := catalog.ObservedStore()
now := time.Now()
if err := store.SaveTask(ctx, projectRoot, snapshot("task-1", "session-1", 1, now)); err != nil {
t.Fatal(err)
}
if err := store.AppendAuditEvent(ctx, projectRoot, taskmonitor.TaskEvent{Timestamp: now, EventType: "state_change", TaskID: "task-1", SessionID: "session-1", State: taskmonitor.TaskStateRunning}); err != nil {
t.Fatal(err)
}
flushCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := catalog.Flush(flushCtx); err != nil {
t.Fatal(err)
}
page, err := catalog.ListPage(ctx, PageRequest{ProjectKeys: []string{project.Key}, SessionID: "session-1", Limit: 50})
if err != nil || len(page.Items) != 1 || page.Items[0].Task.TaskID != "task-1" {
t.Fatalf("page=%#v err=%v", page, err)
}
events, err := catalog.ListEventPage(ctx, project.Key, "task-1", 0, 50)
if err != nil || len(events.Items) != 1 || events.NextSequence != 1 {
t.Fatalf("events=%#v err=%v", events, err)
}
if err := store.AppendAuditEvent(ctx, projectRoot, taskmonitor.TaskEvent{Timestamp: now.Add(time.Second), EventType: "state_change",
TaskID: "task-1", SessionID: "session-1", State: taskmonitor.TaskStateSucceeded}); err != nil {
t.Fatal(err)
}
if err := catalog.Flush(flushCtx); err != nil {
t.Fatal(err)
}
events, err = catalog.ListEventPage(ctx, project.Key, "task-1", 1, 50)
if err != nil || len(events.Items) != 1 || events.Items[0].Sequence != 2 {
t.Fatalf("incremental events=%#v err=%v", events, err)
}
}
func TestPageCursorIsRevisionBound(t *testing.T) {
t.Parallel()
ctx := context.Background()
root := t.TempDir()
catalog, err := Open(ctx, filepath.Join(t.TempDir(), "tasks.sqlite"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
project, _ := catalog.RegisterProject(ctx, root, "Demo")
store := catalog.ObservedStore()
now := time.Now()
for i, id := range []string{"a", "b", "c"} {
if err := store.SaveTask(ctx, root, snapshot(id, "session", 1, now.Add(time.Duration(i)*time.Minute))); err != nil {
t.Fatal(err)
}
}
flushCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_ = catalog.Flush(flushCtx)
first, err := catalog.ListPage(ctx, PageRequest{ProjectKeys: []string{project.Key}, Limit: 2})
if err != nil || len(first.Items) != 2 || first.NextCursor == "" {
t.Fatalf("first=%#v err=%v", first, err)
}
if err := store.SaveTask(ctx, root, snapshot("d", "session", 1, now.Add(4*time.Minute))); err != nil {
t.Fatal(err)
}
_ = catalog.Flush(flushCtx)
stale, err := catalog.ListPage(ctx, PageRequest{ProjectKeys: []string{project.Key}, Cursor: first.NextCursor})
if err != nil || !stale.StaleCursor || len(stale.Items) != 0 {
t.Fatalf("stale=%#v err=%v", stale, err)
}
}
@@ -1,29 +0,0 @@
package taskcatalog
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
func TestCloseCanBeAwaitedAfterCanceledCaller(t *testing.T) {
catalog, err := Open(context.Background(), filepath.Join(t.TempDir(), "tasks.sqlite"))
if err != nil {
t.Fatalf("open: %v", err)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if err := catalog.Close(canceled); err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("first close: %v", err)
}
ctx, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
if err := catalog.Close(ctx); err != nil {
t.Fatalf("await eventual close: %v", err)
}
if err := catalog.db.Ping(); err == nil {
t.Fatal("database remained open after close completion")
}
}
-10
View File
@@ -1,10 +0,0 @@
package taskcatalog
import "sync"
func (c *Catalog) lockProject(projectKey string) func() {
value, _ := c.projectLocks.LoadOrStore(projectKey, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
-86
View File
@@ -1,86 +0,0 @@
package taskcatalog
import (
"context"
"database/sql"
"path/filepath"
"strings"
"time"
"reasonix/internal/projectiondb"
"reasonix/internal/taskmonitor"
)
// Rebuild replaces only the disposable task projection after replaying the
// authoritative snapshots for the supplied projects into a validated sibling
// database. Event logs remain lazily indexed when a task is expanded.
func Rebuild(ctx context.Context, path string, projects []Project) (Status, error) {
if path == "" {
path = DefaultPath()
}
if strings.TrimSpace(path) == "" {
catalog, err := Open(ctx, "")
if err != nil {
return Status{}, err
}
catalog.reconcileMu.Lock()
catalog.reconcileDone = true
catalog.reconcileMu.Unlock()
if err := reconcileProjects(ctx, catalog, projects); err != nil {
closeRebuildCatalog(catalog)
return catalog.Status(), err
}
status := catalog.Status()
closeRebuildCatalog(catalog)
return status, nil
}
err := projectiondb.Rebuild(ctx, projectiondb.OpenOptions{
Path: path, MemoryName: "task-catalog-rebuild", Migrations: migrations(),
}, func(ctx context.Context, db *sql.DB) error {
catalog := &Catalog{
db: db, store: taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")),
status: Status{State: "ready", Mode: projectiondb.ModeDisk, Path: path},
reconciling: map[string]bool{}, registered: map[string]bool{}, reconcileDone: true,
}
return reconcileProjects(ctx, catalog, projects)
})
if err != nil {
return Status{}, err
}
published, err := Open(ctx, path)
if err != nil {
return Status{}, err
}
status := published.Status()
closeRebuildCatalog(published)
return status, nil
}
func reconcileProjects(ctx context.Context, catalog *Catalog, projects []Project) error {
for _, input := range projects {
project, err := catalog.RegisterProject(ctx, input.Root, input.Label)
if err != nil {
return err
}
if err := catalog.ReconcileProject(ctx, project); err != nil {
return err
}
}
return nil
}
func normalizeProject(project Project) Project {
project.Root = filepath.Clean(strings.TrimSpace(project.Root))
if abs, err := filepath.Abs(project.Root); err == nil {
project.Root = abs
}
project.Key = ProjectKey(project.Root)
project.Label = strings.TrimSpace(project.Label)
return project
}
func closeRebuildCatalog(catalog *Catalog) {
closeCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = catalog.Close(closeCtx)
}
-57
View File
@@ -1,57 +0,0 @@
package taskcatalog
import (
"context"
"path/filepath"
"testing"
"time"
"reasonix/internal/taskmonitor"
)
func TestRebuildReplacesStaleTaskProjection(t *testing.T) {
t.Parallel()
ctx := context.Background()
databasePath := filepath.Join(t.TempDir(), "tasks.sqlite")
staleRoot := t.TempDir()
currentRoot := t.TempDir()
store := taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks"))
now := time.Now()
if err := store.SaveTask(ctx, staleRoot, snapshot("stale-task", "stale-session", 1, now)); err != nil {
t.Fatal(err)
}
if err := store.SaveTask(ctx, currentRoot, snapshot("current-task", "current-session", 1, now)); err != nil {
t.Fatal(err)
}
seed, err := Open(ctx, databasePath)
if err != nil {
t.Fatal(err)
}
staleProject, err := seed.RegisterProject(ctx, staleRoot, "Stale")
if err != nil {
t.Fatal(err)
}
if err := seed.ReconcileProject(ctx, staleProject); err != nil {
t.Fatal(err)
}
if err := seed.Close(ctx); err != nil {
t.Fatal(err)
}
currentProject := normalizeProject(Project{Root: currentRoot, Label: "Current"})
if _, err := Rebuild(ctx, databasePath, []Project{currentProject}); err != nil {
t.Fatal(err)
}
rebuilt, err := Open(ctx, databasePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = rebuilt.Close(context.Background()) })
stalePage, err := rebuilt.ListPage(ctx, PageRequest{ProjectKeys: []string{staleProject.Key}, Limit: 50})
if err != nil || len(stalePage.Items) != 0 {
t.Fatalf("stale page=%#v err=%v", stalePage, err)
}
currentPage, err := rebuilt.ListPage(ctx, PageRequest{ProjectKeys: []string{currentProject.Key}, Limit: 50})
if err != nil || len(currentPage.Items) != 1 || currentPage.Items[0].Task.TaskID != "current-task" {
t.Fatalf("current page=%#v err=%v", currentPage, err)
}
}
-236
View File
@@ -1,236 +0,0 @@
package taskcatalog
import (
"context"
"path/filepath"
"sync"
"reasonix/internal/taskmonitor"
)
type sharedManager struct {
lifecycleMu sync.Mutex
mu sync.RWMutex
catalog *Catalog
pending map[string]string
closing bool
rebuilding bool
generation uint64
opening bool
openDone chan struct{}
openCancel context.CancelFunc
open func(context.Context, string) (*Catalog, error)
rebuild func(context.Context, string, []Project) (Status, error)
}
var shared sharedManager
func ensureShared() {
shared.start()
}
func (m *sharedManager) start() {
m.mu.Lock()
if m.catalog != nil || m.opening || m.closing || m.rebuilding {
m.mu.Unlock()
return
}
m.generation++
generation := m.generation
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
m.opening, m.openDone, m.openCancel = true, done, cancel
openCatalog := m.open
if openCatalog == nil {
openCatalog = Open
}
m.mu.Unlock()
go m.openGeneration(ctx, generation, done, openCatalog)
}
func (m *sharedManager) openGeneration(ctx context.Context, generation uint64, done chan struct{}, openCatalog func(context.Context, string) (*Catalog, error)) {
catalog, err := openCatalog(ctx, "")
seen := map[string]bool{}
for err == nil {
m.mu.Lock()
if m.closing || m.rebuilding || generation != m.generation || ctx.Err() != nil {
m.mu.Unlock()
_ = catalog.Close(context.Background())
catalog = nil
break
}
pending := map[string]string{}
for root, label := range m.pending {
if !seen[root] {
seen[root] = true
pending[root] = label
}
}
if len(pending) == 0 {
m.catalog = catalog
m.pending = nil
m.mu.Unlock()
break
}
m.mu.Unlock()
for root, label := range pending {
_, _ = catalog.RegisterProject(ctx, root, label)
}
}
m.mu.Lock()
if m.openDone == done {
m.opening = false
m.openDone = nil
m.openCancel = nil
}
close(done)
m.mu.Unlock()
}
func Shared() *Catalog {
ensureShared()
shared.mu.RLock()
defer shared.mu.RUnlock()
return shared.catalog
}
// ShutdownShared drains accepted notifications and cancels every shared task
// projection worker. It is only used during process shutdown; authoritative
// task snapshots and event logs have already committed before notifications.
func ShutdownShared(ctx context.Context) error {
return shared.close(ctx)
}
func (m *sharedManager) close(ctx context.Context) error {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
return m.closeLocked(ctx, false)
}
func (m *sharedManager) closeLocked(ctx context.Context, rebuild bool) error {
m.mu.Lock()
m.closing = true
m.rebuilding = rebuild
m.generation++
if m.openCancel != nil {
m.openCancel()
}
done := m.openDone
catalog := m.catalog
m.catalog = nil
if !rebuild {
m.pending = nil
}
m.mu.Unlock()
var flushErr, closeErr error
if catalog != nil {
flushErr = catalog.Flush(ctx)
closeErr = catalog.Close(ctx)
}
if done != nil {
select {
case <-done:
case <-ctx.Done():
if closeErr == nil {
closeErr = ctx.Err()
}
}
}
m.mu.Lock()
m.closing = false
m.rebuilding = rebuild
m.mu.Unlock()
if flushErr != nil {
return flushErr
}
return closeErr
}
func RegisterSharedProject(root, label string) string {
return shared.registerProject(root, label)
}
func (m *sharedManager) registerProject(root, label string) string {
m.start()
key := ProjectKey(root)
m.mu.Lock()
if m.rebuilding {
if m.pending == nil {
m.pending = map[string]string{}
}
m.pending[root] = label
m.mu.Unlock()
return key
}
if m.closing {
m.mu.Unlock()
return key
}
if m.catalog == nil {
if m.pending == nil {
m.pending = map[string]string{}
}
m.pending[root] = label
m.mu.Unlock()
return key
}
catalog := m.catalog
m.mu.Unlock()
_, _ = catalog.RegisterProject(context.Background(), root, label)
return key
}
type sharedSink struct{}
func (sharedSink) SnapshotChanged(projectRoot, taskID string) {
catalog := sharedCatalogForNotification(projectRoot)
if catalog != nil {
catalog.SnapshotChanged(projectRoot, taskID)
}
}
func (sharedSink) EventsChanged(projectRoot, taskID string) {
catalog := sharedCatalogForNotification(projectRoot)
if catalog != nil {
catalog.EventsChanged(projectRoot, taskID)
}
}
// sharedCatalogForNotification is deliberately SQLite-free. ProjectionSink is
// called after the authoritative task file lock is released, but task saves
// still must never wait for catalog I/O. A notification received while the
// catalog is opening is recovered by the pending project's initial reconcile.
func sharedCatalogForNotification(projectRoot string) *Catalog {
return shared.catalogForNotification(projectRoot)
}
func (m *sharedManager) catalogForNotification(projectRoot string) *Catalog {
m.start()
m.mu.Lock()
defer m.mu.Unlock()
if m.rebuilding {
if m.pending == nil {
m.pending = map[string]string{}
}
m.pending[projectRoot] = filepath.Base(projectRoot)
return nil
}
if m.closing {
return nil
}
if m.catalog == nil {
if m.pending == nil {
m.pending = map[string]string{}
}
m.pending[projectRoot] = filepath.Base(projectRoot)
return nil
}
return m.catalog
}
// ObservedStore remains an authoritative FileStore; only its post-commit sink
// is shared with the disposable catalog.
func ObservedStore() taskmonitor.WriteStore {
ensureShared()
return taskmonitor.NewObservedFileStore(filepath.Join(".reasonix", "tasks"), sharedSink{})
}
@@ -1,65 +0,0 @@
package taskcatalog
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestSharedManagerCloseFencesOpenAndAllowsRestart(t *testing.T) {
manager := &sharedManager{}
started := make(chan struct{})
release := make(chan struct{})
databasePath := filepath.Join(t.TempDir(), "tasks.sqlite")
manager.open = func(ctx context.Context, path string) (*Catalog, error) {
close(started)
<-release
return Open(ctx, databasePath)
}
manager.start()
<-started
closed := make(chan error, 1)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() { closed <- manager.close(ctx) }()
select {
case err := <-closed:
t.Fatalf("close returned before in-flight open exited: %v", err)
default:
}
close(release)
if err := <-closed; err != nil {
t.Fatalf("close after open fence: %v", err)
}
manager.mu.RLock()
stale := manager.catalog
manager.mu.RUnlock()
if stale != nil {
t.Fatal("stale open published a task catalog after close")
}
manager.mu.Lock()
manager.open = func(ctx context.Context, path string) (*Catalog, error) {
return Open(ctx, databasePath)
}
manager.mu.Unlock()
manager.start()
manager.mu.RLock()
reopened := manager.openDone
manager.mu.RUnlock()
if reopened == nil {
t.Fatal("manager did not start a new generation after close")
}
<-reopened
manager.mu.RLock()
restarted := manager.catalog
manager.mu.RUnlock()
if restarted == nil {
t.Fatal("manager did not publish the restarted task catalog")
}
if err := manager.close(ctx); err != nil {
t.Fatalf("close restarted task catalog: %v", err)
}
}
-52
View File
@@ -1,52 +0,0 @@
package taskcatalog
import "context"
// RebuildSharedCatalog fences the process-local reader, atomically replaces
// the disposable database, and reopens it with every project registered during
// or before the rebuild.
func RebuildSharedCatalog(ctx context.Context, projects []Project) error {
return shared.rebuildShared(ctx, projects)
}
func (m *sharedManager) rebuildShared(ctx context.Context, projects []Project) error {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
if err := m.closeLocked(ctx, true); err != nil {
m.mu.Lock()
m.rebuilding = false
if m.pending == nil {
m.pending = map[string]string{}
}
for _, project := range projects {
project = normalizeProject(project)
m.pending[project.Root] = project.Label
}
m.mu.Unlock()
if ctx.Err() == nil {
m.start()
}
return err
}
m.mu.Lock()
if m.pending == nil {
m.pending = map[string]string{}
}
for _, project := range projects {
project = normalizeProject(project)
m.pending[project.Root] = project.Label
}
rebuild := m.rebuild
if rebuild == nil {
rebuild = Rebuild
}
m.mu.Unlock()
_, rebuildErr := rebuild(ctx, "", projects)
m.mu.Lock()
m.rebuilding = false
m.mu.Unlock()
if ctx.Err() == nil {
m.start()
}
return rebuildErr
}
@@ -1,74 +0,0 @@
package taskcatalog
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestSharedManagerRebuildRestartsWithRegisteredProjects(t *testing.T) {
manager := &sharedManager{}
databasePath := filepath.Join(t.TempDir(), "tasks.sqlite")
manager.open = func(ctx context.Context, _ string) (*Catalog, error) {
return Open(ctx, databasePath)
}
rebuildCalls := 0
manager.rebuild = func(context.Context, string, []Project) (Status, error) {
rebuildCalls++
return Status{}, nil
}
manager.start()
waitSharedManagerOpen(t, manager)
project := Project{Root: t.TempDir(), Label: "Current"}
if err := manager.rebuildShared(context.Background(), []Project{project}); err != nil {
t.Fatal(err)
}
waitSharedManagerOpen(t, manager)
if rebuildCalls != 1 {
t.Fatalf("rebuild calls = %d, want 1", rebuildCalls)
}
manager.mu.RLock()
restarted := manager.catalog
manager.mu.RUnlock()
if restarted == nil {
t.Fatal("manager did not publish a fresh catalog after rebuild")
}
normalized := normalizeProject(project)
if _, ok, err := restarted.Project(context.Background(), normalized.Key); err != nil || !ok {
t.Fatalf("registered project missing after rebuild: ok=%v err=%v", ok, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := manager.close(ctx); err != nil {
t.Fatal(err)
}
}
func TestSharedManagerRetainsRegistrationsWhileClosingForRebuild(t *testing.T) {
manager := &sharedManager{closing: true, rebuilding: true}
registeredRoot := t.TempDir()
notifiedRoot := t.TempDir()
manager.registerProject(registeredRoot, "Registered")
if catalog := manager.catalogForNotification(notifiedRoot); catalog != nil {
t.Fatal("notification unexpectedly reached a catalog during rebuild")
}
manager.mu.RLock()
defer manager.mu.RUnlock()
if manager.pending[registeredRoot] != "Registered" {
t.Fatalf("registered project was not retained: %#v", manager.pending)
}
if manager.pending[notifiedRoot] != filepath.Base(notifiedRoot) {
t.Fatalf("notified project was not retained: %#v", manager.pending)
}
}
func waitSharedManagerOpen(t *testing.T, manager *sharedManager) {
t.Helper()
manager.mu.RLock()
done := manager.openDone
manager.mu.RUnlock()
if done != nil {
<-done
}
}