Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Request `synchronous=FULL` on every SQLite connection and, on macOS, `fullfsync` for commits and checkpoints. Go test binaries skip the macOS full flush.
- Let the native code producer publish from a captured upstream commit when core progress is fresh, even while provider coverage is incomplete. Retries and coverage are not changed.
- Add `portable export --compression split`, which publishes the uncompressed portable database as verified 32 MiB parts so Git stores each new generation as a small delta instead of a new 68 MB gzip blob. Refresh, init, status and recovery read split stores; older readers refuse them and keep their last good mirror. After advancing to a split generation, strict refresh geometrically repacks recent objects so subscribers also keep each generation as a delta, skipping a loose backlog of more than 512 objects. Raw and gzip stores are still never repacked.
- Record every accepted native cluster run's complete computed partition as an immutable, run-scoped `gitcrawl.cluster-candidates.v3` generation, with observed overlaps to durable groups, and select it atomically. Quarantined candidates are visible in this domain without changing durable IDs or their history. Add `cluster-candidates` (also in the native reader) and `--rollback`.

## 0.16.0 - 2026-10-20

Expand Down
38 changes: 38 additions & 0 deletions docs/clustering.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,44 @@ Durable cluster IDs are derived from the representative thread, so they survive

Cluster runs are recorded in `run_records` and visible via `gitcrawl runs --kind cluster`.

## Computed candidate generations

The native available-subset producer keeps durable IDs stable by quarantining
any computed candidate whose identity is ambiguous: merges, splits and
protected targets. Those threads keep their retained durable membership, but
the current computation would otherwise be invisible. Every accepted native run
now also records its complete computed partition as an immutable
`gitcrawl.cluster-candidates.v3` generation, and selects it, in the same
transaction:

- Candidate IDs are `GENERATION:ORDINAL`. They are scoped to that run and are
not claimed stable across topology changes. Durable IDs remain the stable,
curated domain.
- `observed_overlaps` lists durable groups that share active members with a
candidate. These are lineage observations, not aliases or equivalence. The
six durable history tables (`cluster_groups`, `cluster_memberships`,
`cluster_overrides`, `cluster_closures`, `cluster_aliases`,
`cluster_events`) are never written by generations.
- `durable_status` says whether the v2 run published the candidate under a
durable ID (`published`, with `durable_cluster_id`) or quarantined it
(`identity_quarantined`). Both are present in the computed generation.
- Generation rows cannot be updated or deleted, with one exception: owner
removal (`purge`) deletes a removed thread, then its member rows, and readers derive
counts and representatives from the remaining members.
- `cluster-candidates --rollback` selects the previous generation. The next
accepted run selects its own generation again. A ref no-op run records
nothing.

```bash
gitcrawl cluster-candidates openclaw/openclaw --limit 20 --json
gitcrawl cluster-candidates openclaw/openclaw --generations --json
```

The dedicated native cluster reader also permits `cluster-candidates`, but not
`--rollback`. Strict v2 scope metadata is unchanged; consumers that need
current computed clusters read this explicit, versioned domain rather than
reinterpreting v2 scope fields.

## See also

- [Governance](/governance/) — close clusters, exclude members, set canonical
Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ This applies to `sync --numbers`, `threads --numbers`, `summarize --number`, `em
| `gitcrawl durable-clusters owner/repo [--include-closed --sort --min-size --limit --json]` | Strict durable-cluster audit view | [Clustering](/clustering/#list-clusters) |
| `gitcrawl cluster-detail owner/repo --id <n> [--source auto\|run\|durable --member-limit --body-chars --hide-closed --json]` | Cluster + members detail | [Clustering](/clustering/#inspect-a-cluster) |
| `gitcrawl cluster-explain owner/repo --id <n> [...]` | Alias for `cluster-detail` | [Clustering](/clustering/#inspect-a-cluster) |
| `gitcrawl cluster-candidates owner/repo [--generation N --generations --limit N --members --rollback --json]` | Run-scoped computed candidate generations | [Clustering](/clustering/#computed-candidate-generations) |

## Governance

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ func (a *App) Run(ctx context.Context, args []string) error {
return a.runClusterDetail(ctx, rest[1:])
case "cluster-explain":
return a.runClusterDetail(ctx, rest[1:])
case "cluster-candidates":
return a.runClusterCandidates(ctx, rest[1:], false)
case "neighbors":
return a.runNeighbors(ctx, rest[1:])
case "cluster":
Expand Down
113 changes: 113 additions & 0 deletions internal/cli/cluster_candidates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cli

import (
"context"
"errors"
"flag"
"fmt"
"io"

"github.com/openclaw/gitcrawl/internal/store"
)

// runClusterCandidates reads the selected (or an explicit) computed candidate
// generation. Candidate IDs are run-scoped and never durable identities.
// Rollback is a local owner action and is unavailable in the cluster reader.
func (a *App) runClusterCandidates(ctx context.Context, args []string, readerOnly bool) error {
fs := flag.NewFlagSet("cluster-candidates", flag.ContinueOnError)
fs.SetOutput(io.Discard)
generationRaw := fs.String("generation", "", "generation id (default: selected)")
limitRaw := fs.String("limit", "", "maximum candidates")
members := fs.Bool("members", false, "include member thread ids")
listGenerations := fs.Bool("generations", false, "list recent generations instead of candidates")
rollback := fs.Bool("rollback", false, "select the previous generation")
jsonOut := fs.Bool("json", false, "write JSON output")
if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"generation": true, "limit": true})); err != nil {
return usageErr(err)
}
a.applyCommandJSON(*jsonOut)
if fs.NArg() != 1 {
return usageErr(fmt.Errorf("cluster-candidates requires owner/repo"))
}
owner, repoName, err := parseOwnerRepo(fs.Arg(0))
if err != nil {
return usageErr(err)
}
generationID, err := parseOptionalPositiveInt(*generationRaw)
if err != nil {
return usageErr(err)
}
limit, err := parseOptionalPositiveInt(*limitRaw)
if err != nil {
return usageErr(err)
}
if *rollback && (readerOnly || generationID != 0 || *listGenerations) {
return usageErr(fmt.Errorf("--rollback is a local owner action without --generation or --generations"))
}
if *rollback {
rt, err := a.openLocalRuntime(ctx)
if err != nil {
return err
}
defer rt.Store.Close()
repo, err := rt.repository(ctx, owner, repoName)
if err != nil {
return err
}
generation, err := rt.Store.RollbackClusterCandidateGeneration(ctx, repo.ID)
if err != nil {
return err
}
return a.writeOutput("cluster-candidates", map[string]any{"repository": repo.FullName, "selected_generation": generation}, false)
}
rt, err := a.openLocalRuntimeReadOnly(ctx)
if err != nil {
return err
}
defer rt.Store.Close()
repo, err := rt.repository(ctx, owner, repoName)
if err != nil {
return err
}
// One read transaction gives each response a single database snapshot,
// so selection flags, counts and members cannot straddle a rollback or an
// owner purge.
var generations []store.ClusterCandidateGeneration
var generation store.ClusterCandidateGeneration
var candidates []store.ClusterCandidate
err = rt.Store.WithTx(ctx, func(tx *store.Store) error {
var err error
if *listGenerations {
generations, err = tx.ListClusterCandidateGenerations(ctx, repo.ID, limit)
return err
}
if generationID != 0 {
generation, err = tx.ClusterCandidateGenerationByID(ctx, repo.ID, int64(generationID))
} else {
generation, err = tx.SelectedClusterCandidateGeneration(ctx, repo.ID)
}
if err != nil {
return err
}
candidates, err = tx.ClusterCandidates(ctx, repo.ID, generation.ID, limit, *members)
return err
})
if errors.Is(err, store.ErrNoClusterCandidateGeneration) {
return fmt.Errorf("%w for %s", err, repo.FullName)
}
if err != nil {
return err
}
if *listGenerations {
return a.writeOutput("cluster-candidates", map[string]any{"repository": repo.FullName, "format": store.ClusterCandidateFormatV3, "generations": generations}, false)
}
return a.writeOutput("cluster-candidates", map[string]any{
"repository": repo.FullName,
"generation": generation,
// Computed IDs are scoped to this generation. Overlaps are observed
// shared members with durable groups, not equivalence.
"identity_domain": "computed-run-scoped",
"identity_reconciliation_complete": false,
"candidates": candidates,
}, false)
}
122 changes: 122 additions & 0 deletions internal/cli/cluster_candidates_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"time"

"github.com/openclaw/gitcrawl/internal/store"
)

func TestClusterCandidatesCommandReadsSelectsAndRollsBack(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
dbPath := filepath.Join(dir, "gitcrawl.db")
initApp := New()
initApp.Stdout, initApp.Stderr = &bytes.Buffer{}, &bytes.Buffer{}
if err := initApp.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath, "--json"}); err != nil {
t.Fatal(err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Format(time.RFC3339Nano)
repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
var ids []int64
for i := 1; i <= 3; i++ {
id, err := st.UpsertThread(ctx, store.Thread{RepoID: repoID, GitHubID: fmt.Sprint(i), Number: i, Kind: "issue", State: "open", Title: "t", HTMLURL: "https://github.com/openclaw/openclaw/issues/1", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: fmt.Sprint(i), UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
ids = append(ids, id)
}
for generation := 0; generation < 2; generation++ {
plan := store.ClusterQuarantinePlan{RepositoryID: repoID, SelectedIDs: ids,
Candidates: []store.DurableClusterInput{
{StableKey: "a", RepresentativeThreadID: ids[0], Members: []store.DurableClusterMemberInput{{ThreadID: ids[0]}, {ThreadID: ids[1]}}},
{StableKey: "b", RepresentativeThreadID: ids[2], Members: []store.DurableClusterMemberInput{{ThreadID: ids[2]}}}},
Identities: []store.ClusterIdentityMetadata{{ID: 7, Active: true, ActiveMembers: []int64{ids[0]}}},
Targets: []store.ClusterPublicationTarget{{CandidateKey: "b", ClusterID: 7}}}
result, err := st.DB().ExecContext(ctx, "insert into cluster_runs(repo_id, scope, status, started_at) values(?, 'durable', 'success', ?)", repoID, now)
if err != nil {
t.Fatal(err)
}
runID, err := result.LastInsertId()
if err != nil {
t.Fatal(err)
}
if err := st.WithTx(ctx, func(tx *store.Store) error {
_, err := tx.SaveClusterCandidateGeneration(ctx, store.ClusterCandidateGenerationInput{RepoID: repoID, RunID: runID, Algorithm: "test",
InputSHA256: strings.Repeat(fmt.Sprint(generation), 64), QuarantinePlanSHA256: strings.Repeat("b", 64), Plan: plan, PublishedClusterIDs: map[string]int64{"b": 7}})
return err
}); err != nil {
t.Fatal(err)
}
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
run := func(reader bool, args ...string) (map[string]any, error) {
app := New()
var stdout bytes.Buffer
app.Stdout, app.Stderr = &stdout, &bytes.Buffer{}
full := append([]string{"--config", configPath, "--json", "cluster-candidates", "openclaw/openclaw"}, args...)
if reader {
err = app.RunClusterReader(ctx, full)
} else {
err = app.Run(ctx, full)
}
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", stdout.String(), err)
}
return payload, nil
}
current, err := run(true, "--members")
if err != nil {
t.Fatal(err)
}
generation := current["generation"].(map[string]any)
candidates := current["candidates"].([]any)
if generation["generation_id"].(float64) != 2 || current["identity_domain"] != "computed-run-scoped" || current["identity_reconciliation_complete"] != false || len(candidates) != 2 {
t.Fatalf("selected generation output: %+v", current)
}
first := candidates[0].(map[string]any)
if first["computed_candidate_id"] != "2:1" || first["durable_status"] != "identity_quarantined" || len(first["observed_overlaps"].([]any)) != 1 || len(first["members"].([]any)) != 2 {
t.Fatalf("candidate output: %+v", first)
}
if _, err := run(true, "--rollback"); err == nil {
t.Fatal("cluster reader allowed rollback")
}
rolled, err := run(false, "--rollback")
if err != nil || rolled["selected_generation"].(map[string]any)["generation_id"].(float64) != 1 {
t.Fatalf("rollback output=%+v err=%v", rolled, err)
}
listed, err := run(false, "--generations")
if err != nil || len(listed["generations"].([]any)) != 2 || listed["generations"].([]any)[1].(map[string]any)["selected"] != true {
t.Fatalf("generation list=%+v err=%v", listed, err)
}
explicit, err := run(true, "--generation", "2", "--limit", "1")
if err != nil || len(explicit["candidates"].([]any)) != 1 || explicit["generation"].(map[string]any)["selected"] != false {
t.Fatalf("explicit generation=%+v err=%v", explicit, err)
}
if _, err := run(true, "--generation", "9"); err == nil {
t.Fatal("unknown generation accepted")
}
if _, err := run(false, "--rollback", "--generation", "1"); err == nil {
t.Fatal("rollback with explicit generation accepted")
}
}
6 changes: 4 additions & 2 deletions internal/cli/cluster_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (a *App) RunClusterReader(ctx context.Context, args []string) error {
command = rest[1]
}
switch command {
case "clusters", "durable-clusters", "clusters-report", "cluster-detail":
case "clusters", "durable-clusters", "clusters-report", "cluster-detail", "cluster-candidates":
default:
return usageErr(fmt.Errorf("cluster reader supports local cluster reads only"))
}
Expand Down Expand Up @@ -62,10 +62,12 @@ func (a *App) RunClusterReader(ctx context.Context, args []string) error {
return a.runClustersReport(ctx, rest[1:])
case "cluster-detail":
return a.runClusterDetail(ctx, rest[1:])
case "cluster-candidates":
return a.runClusterCandidates(ctx, rest[1:], true)
}
return usageErr(fmt.Errorf("unsupported cluster read"))
}
func (a *App) clusterReaderUsage() error {
_, err := fmt.Fprintln(a.Stdout, "Usage: gitcrawl-cluster-reader --config SOURCE_CONFIG clusters|durable-clusters|clusters-report|cluster-detail [options]\nLocal read-only cluster reader; no collection, embedding or provider commands.")
_, err := fmt.Fprintln(a.Stdout, "Usage: gitcrawl-cluster-reader --config SOURCE_CONFIG clusters|durable-clusters|clusters-report|cluster-detail|cluster-candidates [options]\nLocal read-only cluster reader; no collection, embedding or provider commands.")
return err
}
14 changes: 14 additions & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Core commands:
durable-clusters list durable cluster groups
cluster-detail dump one latest run cluster, with durable fallback
cluster-explain alias for cluster-detail
cluster-candidates read run-scoped computed candidate generations
neighbors list vector-nearest local issue and pull request rows
search search local thread and source documents; also supports search issues|prs gh syntax
gh moved to Octopool; prints migration note
Expand Down Expand Up @@ -254,6 +255,19 @@ Usage:

Usage:
gitcrawl cluster-detail owner/repo --id N [--source auto|run|durable] [--member-limit N] [--body-chars N] [--hide-closed] [--json]
`,
"cluster-candidates": `gitcrawl cluster-candidates reads computed candidate generations.

Each accepted native cluster run records its complete computed partition as an
immutable generation and selects it. Candidate IDs (GENERATION:ORDINAL) are
run-scoped, never durable cluster IDs; observed_overlaps list shared members
with durable groups and do not claim equivalence. --rollback selects the
previous generation; the next accepted run selects its own again.

Usage:
gitcrawl cluster-candidates owner/repo [--generation N] [--limit N] [--members] [--json]
gitcrawl cluster-candidates owner/repo --generations [--limit N] [--json]
gitcrawl cluster-candidates owner/repo --rollback [--json]
`,
"neighbors": `gitcrawl neighbors lists vector-nearest local issue and pull request rows.

Expand Down
Loading
Loading