tangled
alpha
login
or
join now
atscan.net
/
plcbundle-go
1
fork
atom
[DEPRECATED] Go implementation of plcbundle
1
fork
atom
overview
issues
pulls
pipelines
update structure (5)
tree.fail
4 months ago
b3a1b9d7
aa875f24
+4332
-4291
33 changed files
expand all
collapse all
unified
split
bundle.go
cmd
plcbundle
commands
backfill.go
clone.go
common.go
compare.go
detector.go
export.go
fetch.go
getop.go
index.go
info.go
mempool.go
rebuild.go
server.go
verify.go
version.go
compare.go
did_index.go
get_op.go
main.go
progress.go
server.go
ui
progress.go
internal
bundle
manager.go
sync
fetcher.go
options.go
server
handlers.go
helpers.go
middleware.go
server.go
types.go
websocket.go
types.go
+57
bundle.go
···
1
1
+
package plcbundle
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"io"
6
6
+
7
7
+
"tangled.org/atscan.net/plcbundle/internal/bundle"
8
8
+
)
9
9
+
10
10
+
// Manager is the main entry point for plcbundle operations
11
11
+
type Manager struct {
12
12
+
internal *bundle.Manager
13
13
+
}
14
14
+
15
15
+
// New creates a new plcbundle manager
16
16
+
func New(opts ...Option) (*Manager, error) {
17
17
+
config := defaultConfig()
18
18
+
for _, opt := range opts {
19
19
+
opt(config)
20
20
+
}
21
21
+
22
22
+
mgr, err := bundle.NewManager(config.bundleConfig, config.plcClient)
23
23
+
if err != nil {
24
24
+
return nil, err
25
25
+
}
26
26
+
27
27
+
return &Manager{internal: mgr}, nil
28
28
+
}
29
29
+
30
30
+
// LoadBundle loads a bundle by number
31
31
+
func (m *Manager) LoadBundle(ctx context.Context, bundleNumber int) (*Bundle, error) {
32
32
+
b, err := m.internal.LoadBundle(ctx, bundleNumber)
33
33
+
if err != nil {
34
34
+
return nil, err
35
35
+
}
36
36
+
return toBundlePublic(b), nil
37
37
+
}
38
38
+
39
39
+
// StreamBundleRaw streams raw compressed bundle data
40
40
+
func (m *Manager) StreamBundleRaw(ctx context.Context, bundleNumber int) (io.ReadCloser, error) {
41
41
+
return m.internal.StreamBundleRaw(ctx, bundleNumber)
42
42
+
}
43
43
+
44
44
+
// Close closes the manager and releases resources
45
45
+
func (m *Manager) Close() error {
46
46
+
m.internal.Close()
47
47
+
return nil
48
48
+
}
49
49
+
50
50
+
// FetchNextBundle fetches the next bundle from PLC directory
51
51
+
func (m *Manager) FetchNextBundle(ctx context.Context) (*Bundle, error) {
52
52
+
b, err := m.internal.FetchNextBundle(ctx, false)
53
53
+
if err != nil {
54
54
+
return nil, err
55
55
+
}
56
56
+
return toBundlePublic(b), nil
57
57
+
}
+110
cmd/plcbundle/commands/backfill.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"os"
8
8
+
)
9
9
+
10
10
+
// BackfillCommand handles the backfill subcommand
11
11
+
func BackfillCommand(args []string) error {
12
12
+
fs := flag.NewFlagSet("backfill", flag.ExitOnError)
13
13
+
plcURL := fs.String("plc", "https://plc.directory", "PLC directory URL")
14
14
+
startFrom := fs.Int("start", 1, "bundle number to start from")
15
15
+
endAt := fs.Int("end", 0, "bundle number to end at (0 = until caught up)")
16
16
+
verbose := fs.Bool("verbose", false, "verbose sync logging")
17
17
+
18
18
+
if err := fs.Parse(args); err != nil {
19
19
+
return err
20
20
+
}
21
21
+
22
22
+
mgr, dir, err := getManager(*plcURL)
23
23
+
if err != nil {
24
24
+
return err
25
25
+
}
26
26
+
defer mgr.Close()
27
27
+
28
28
+
fmt.Fprintf(os.Stderr, "Starting backfill from: %s\n", dir)
29
29
+
fmt.Fprintf(os.Stderr, "Starting from bundle: %06d\n", *startFrom)
30
30
+
if *endAt > 0 {
31
31
+
fmt.Fprintf(os.Stderr, "Ending at bundle: %06d\n", *endAt)
32
32
+
} else {
33
33
+
fmt.Fprintf(os.Stderr, "Ending: when caught up\n")
34
34
+
}
35
35
+
fmt.Fprintf(os.Stderr, "\n")
36
36
+
37
37
+
ctx := context.Background()
38
38
+
39
39
+
currentBundle := *startFrom
40
40
+
processedCount := 0
41
41
+
fetchedCount := 0
42
42
+
loadedCount := 0
43
43
+
operationCount := 0
44
44
+
45
45
+
for {
46
46
+
if *endAt > 0 && currentBundle > *endAt {
47
47
+
break
48
48
+
}
49
49
+
50
50
+
fmt.Fprintf(os.Stderr, "Processing bundle %06d... ", currentBundle)
51
51
+
52
52
+
// Try to load from disk first
53
53
+
bundle, err := mgr.LoadBundle(ctx, currentBundle)
54
54
+
55
55
+
if err != nil {
56
56
+
// Bundle doesn't exist, fetch it
57
57
+
fmt.Fprintf(os.Stderr, "fetching... ")
58
58
+
59
59
+
bundle, err = mgr.FetchNextBundle(ctx, !*verbose)
60
60
+
if err != nil {
61
61
+
if isEndOfDataError(err) {
62
62
+
fmt.Fprintf(os.Stderr, "\n✓ Caught up! No more complete bundles available.\n")
63
63
+
break
64
64
+
}
65
65
+
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
66
66
+
break
67
67
+
}
68
68
+
69
69
+
if err := mgr.SaveBundle(ctx, bundle, !*verbose); err != nil {
70
70
+
return fmt.Errorf("error saving: %w", err)
71
71
+
}
72
72
+
73
73
+
fetchedCount++
74
74
+
fmt.Fprintf(os.Stderr, "saved... ")
75
75
+
} else {
76
76
+
loadedCount++
77
77
+
}
78
78
+
79
79
+
// Output operations to stdout (JSONL)
80
80
+
for _, op := range bundle.Operations {
81
81
+
if len(op.RawJSON) > 0 {
82
82
+
fmt.Println(string(op.RawJSON))
83
83
+
}
84
84
+
}
85
85
+
86
86
+
operationCount += len(bundle.Operations)
87
87
+
processedCount++
88
88
+
89
89
+
fmt.Fprintf(os.Stderr, "✓ (%d ops, %d DIDs)\n", len(bundle.Operations), bundle.DIDCount)
90
90
+
91
91
+
currentBundle++
92
92
+
93
93
+
// Progress summary every 100 bundles
94
94
+
if processedCount%100 == 0 {
95
95
+
fmt.Fprintf(os.Stderr, "\n--- Progress: %d bundles processed (%d fetched, %d loaded) ---\n",
96
96
+
processedCount, fetchedCount, loadedCount)
97
97
+
fmt.Fprintf(os.Stderr, " Total operations: %d\n\n", operationCount)
98
98
+
}
99
99
+
}
100
100
+
101
101
+
// Final summary
102
102
+
fmt.Fprintf(os.Stderr, "\n✓ Backfill complete\n")
103
103
+
fmt.Fprintf(os.Stderr, " Bundles processed: %d\n", processedCount)
104
104
+
fmt.Fprintf(os.Stderr, " Newly fetched: %d\n", fetchedCount)
105
105
+
fmt.Fprintf(os.Stderr, " Loaded from disk: %d\n", loadedCount)
106
106
+
fmt.Fprintf(os.Stderr, " Total operations: %d\n", operationCount)
107
107
+
fmt.Fprintf(os.Stderr, " Range: %06d - %06d\n", *startFrom, currentBundle-1)
108
108
+
109
109
+
return nil
110
110
+
}
+157
cmd/plcbundle/commands/clone.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"os"
8
8
+
"os/signal"
9
9
+
"strings"
10
10
+
"sync"
11
11
+
"syscall"
12
12
+
"time"
13
13
+
14
14
+
"tangled.org/atscan.net/plcbundle/cmd/plcbundle/ui"
15
15
+
internalsync "tangled.org/atscan.net/plcbundle/internal/sync"
16
16
+
)
17
17
+
18
18
+
// CloneCommand handles the clone subcommand
19
19
+
func CloneCommand(args []string) error {
20
20
+
fs := flag.NewFlagSet("clone", flag.ExitOnError)
21
21
+
workers := fs.Int("workers", 4, "number of concurrent download workers")
22
22
+
verbose := fs.Bool("v", false, "verbose output")
23
23
+
skipExisting := fs.Bool("skip-existing", true, "skip bundles that already exist locally")
24
24
+
saveInterval := fs.Duration("save-interval", 5*time.Second, "interval to save index during download")
25
25
+
26
26
+
if err := fs.Parse(args); err != nil {
27
27
+
return err
28
28
+
}
29
29
+
30
30
+
if fs.NArg() < 1 {
31
31
+
return fmt.Errorf("usage: plcbundle clone <remote-url> [options]\n\n" +
32
32
+
"Clone bundles from a remote plcbundle HTTP endpoint\n\n" +
33
33
+
"Example:\n" +
34
34
+
" plcbundle clone https://plc.example.com")
35
35
+
}
36
36
+
37
37
+
remoteURL := strings.TrimSuffix(fs.Arg(0), "/")
38
38
+
39
39
+
// Create manager
40
40
+
mgr, dir, err := getManager("")
41
41
+
if err != nil {
42
42
+
return err
43
43
+
}
44
44
+
defer mgr.Close()
45
45
+
46
46
+
fmt.Printf("Cloning from: %s\n", remoteURL)
47
47
+
fmt.Printf("Target directory: %s\n", dir)
48
48
+
fmt.Printf("Workers: %d\n", *workers)
49
49
+
fmt.Printf("(Press Ctrl+C to safely interrupt - progress will be saved)\n\n")
50
50
+
51
51
+
// Set up signal handling
52
52
+
ctx, cancel := context.WithCancel(context.Background())
53
53
+
defer cancel()
54
54
+
55
55
+
sigChan := make(chan os.Signal, 1)
56
56
+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
57
57
+
58
58
+
// Set up progress bar
59
59
+
var progress *ui.ProgressBar
60
60
+
var progressMu sync.Mutex
61
61
+
progressActive := true
62
62
+
63
63
+
go func() {
64
64
+
<-sigChan
65
65
+
progressMu.Lock()
66
66
+
progressActive = false
67
67
+
if progress != nil {
68
68
+
fmt.Println()
69
69
+
}
70
70
+
progressMu.Unlock()
71
71
+
72
72
+
fmt.Printf("\n⚠️ Interrupt received! Finishing current downloads and saving progress...\n")
73
73
+
cancel()
74
74
+
}()
75
75
+
76
76
+
// Clone with library
77
77
+
result, err := mgr.CloneFromRemote(ctx, internalsync.CloneOptions{
78
78
+
RemoteURL: remoteURL,
79
79
+
Workers: *workers,
80
80
+
SkipExisting: *skipExisting,
81
81
+
SaveInterval: *saveInterval,
82
82
+
Verbose: *verbose,
83
83
+
ProgressFunc: func(downloaded, total int, bytesDownloaded, bytesTotal int64) {
84
84
+
progressMu.Lock()
85
85
+
defer progressMu.Unlock()
86
86
+
87
87
+
if !progressActive {
88
88
+
return
89
89
+
}
90
90
+
91
91
+
if progress == nil {
92
92
+
progress = ui.NewProgressBarWithBytes(total, bytesTotal)
93
93
+
}
94
94
+
progress.SetWithBytes(downloaded, bytesDownloaded)
95
95
+
},
96
96
+
})
97
97
+
98
98
+
// Ensure progress is stopped
99
99
+
progressMu.Lock()
100
100
+
progressActive = false
101
101
+
if progress != nil {
102
102
+
progress.Finish()
103
103
+
}
104
104
+
progressMu.Unlock()
105
105
+
106
106
+
if err != nil {
107
107
+
return fmt.Errorf("clone failed: %w", err)
108
108
+
}
109
109
+
110
110
+
// Display results
111
111
+
if result.Interrupted {
112
112
+
fmt.Printf("⚠️ Download interrupted by user\n")
113
113
+
} else {
114
114
+
fmt.Printf("\n✓ Clone complete in %s\n", result.Duration.Round(time.Millisecond))
115
115
+
}
116
116
+
117
117
+
fmt.Printf("\nResults:\n")
118
118
+
fmt.Printf(" Remote bundles: %d\n", result.RemoteBundles)
119
119
+
if result.Skipped > 0 {
120
120
+
fmt.Printf(" Skipped (existing): %d\n", result.Skipped)
121
121
+
}
122
122
+
fmt.Printf(" Downloaded: %d\n", result.Downloaded)
123
123
+
if result.Failed > 0 {
124
124
+
fmt.Printf(" Failed: %d\n", result.Failed)
125
125
+
}
126
126
+
fmt.Printf(" Total size: %s\n", formatBytes(result.TotalBytes))
127
127
+
128
128
+
if result.Duration.Seconds() > 0 && result.Downloaded > 0 {
129
129
+
mbPerSec := float64(result.TotalBytes) / result.Duration.Seconds() / (1024 * 1024)
130
130
+
bundlesPerSec := float64(result.Downloaded) / result.Duration.Seconds()
131
131
+
fmt.Printf(" Average speed: %.1f MB/s (%.1f bundles/s)\n", mbPerSec, bundlesPerSec)
132
132
+
}
133
133
+
134
134
+
if result.Failed > 0 {
135
135
+
fmt.Printf("\n⚠️ Failed bundles: ")
136
136
+
for i, num := range result.FailedBundles {
137
137
+
if i > 0 {
138
138
+
fmt.Printf(", ")
139
139
+
}
140
140
+
if i > 10 {
141
141
+
fmt.Printf("... and %d more", len(result.FailedBundles)-10)
142
142
+
break
143
143
+
}
144
144
+
fmt.Printf("%06d", num)
145
145
+
}
146
146
+
fmt.Printf("\nRe-run the clone command to retry failed bundles.\n")
147
147
+
return fmt.Errorf("clone completed with errors")
148
148
+
}
149
149
+
150
150
+
if result.Interrupted {
151
151
+
fmt.Printf("\n✓ Progress saved. Re-run the clone command to resume.\n")
152
152
+
return fmt.Errorf("clone interrupted")
153
153
+
}
154
154
+
155
155
+
fmt.Printf("\n✓ Clone complete!\n")
156
156
+
return nil
157
157
+
}
+149
cmd/plcbundle/commands/common.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"os"
7
7
+
"strings"
8
8
+
"time"
9
9
+
10
10
+
"tangled.org/atscan.net/plcbundle/internal/bundle"
11
11
+
"tangled.org/atscan.net/plcbundle/internal/bundleindex"
12
12
+
"tangled.org/atscan.net/plcbundle/internal/didindex"
13
13
+
internalsync "tangled.org/atscan.net/plcbundle/internal/sync"
14
14
+
"tangled.org/atscan.net/plcbundle/plcclient"
15
15
+
)
16
16
+
17
17
+
// BundleManager interface (for testing/mocking)
18
18
+
type BundleManager interface {
19
19
+
Close()
20
20
+
GetIndex() *bundleindex.Index
21
21
+
LoadBundle(ctx context.Context, bundleNumber int) (*bundle.Bundle, error)
22
22
+
VerifyBundle(ctx context.Context, bundleNumber int) (*bundle.VerificationResult, error)
23
23
+
VerifyChain(ctx context.Context) (*bundle.ChainVerificationResult, error)
24
24
+
GetInfo() map[string]interface{}
25
25
+
GetMempoolStats() map[string]interface{}
26
26
+
GetMempoolOperations() ([]plcclient.PLCOperation, error)
27
27
+
ValidateMempool() error
28
28
+
RefreshMempool() error
29
29
+
ClearMempool() error
30
30
+
FetchNextBundle(ctx context.Context, quiet bool) (*bundle.Bundle, error)
31
31
+
SaveBundle(ctx context.Context, b *bundle.Bundle, quiet bool) error
32
32
+
GetDIDIndexStats() map[string]interface{}
33
33
+
GetDIDIndex() *didindex.Manager
34
34
+
BuildDIDIndex(ctx context.Context, progress func(int, int)) error
35
35
+
GetDIDOperationsWithLocations(ctx context.Context, did string, verbose bool) ([]bundle.PLCOperationWithLocation, error)
36
36
+
GetDIDOperationsFromMempool(did string) ([]plcclient.PLCOperation, error)
37
37
+
GetLatestDIDOperation(ctx context.Context, did string) (*plcclient.PLCOperation, error)
38
38
+
LoadOperation(ctx context.Context, bundleNum, position int) (*plcclient.PLCOperation, error)
39
39
+
CloneFromRemote(ctx context.Context, opts internalsync.CloneOptions) (*internalsync.CloneResult, error)
40
40
+
}
41
41
+
42
42
+
// PLCOperationWithLocation wraps operation with location info
43
43
+
type PLCOperationWithLocation = bundle.PLCOperationWithLocation
44
44
+
45
45
+
// getManager creates or opens a bundle manager
46
46
+
func getManager(plcURL string) (*bundle.Manager, string, error) {
47
47
+
dir, err := os.Getwd()
48
48
+
if err != nil {
49
49
+
return nil, "", err
50
50
+
}
51
51
+
52
52
+
if err := os.MkdirAll(dir, 0755); err != nil {
53
53
+
return nil, "", fmt.Errorf("failed to create directory: %w", err)
54
54
+
}
55
55
+
56
56
+
config := bundle.DefaultConfig(dir)
57
57
+
58
58
+
var client *plcclient.Client
59
59
+
if plcURL != "" {
60
60
+
client = plcclient.NewClient(plcURL)
61
61
+
}
62
62
+
63
63
+
mgr, err := bundle.NewManager(config, client)
64
64
+
if err != nil {
65
65
+
return nil, "", err
66
66
+
}
67
67
+
68
68
+
return mgr, dir, nil
69
69
+
}
70
70
+
71
71
+
// parseBundleRange parses bundle range string
72
72
+
func parseBundleRange(rangeStr string) (start, end int, err error) {
73
73
+
if !strings.Contains(rangeStr, "-") {
74
74
+
var num int
75
75
+
_, err = fmt.Sscanf(rangeStr, "%d", &num)
76
76
+
if err != nil {
77
77
+
return 0, 0, fmt.Errorf("invalid bundle number: %w", err)
78
78
+
}
79
79
+
return num, num, nil
80
80
+
}
81
81
+
82
82
+
parts := strings.Split(rangeStr, "-")
83
83
+
if len(parts) != 2 {
84
84
+
return 0, 0, fmt.Errorf("invalid range format (expected: N or start-end)")
85
85
+
}
86
86
+
87
87
+
_, err = fmt.Sscanf(parts[0], "%d", &start)
88
88
+
if err != nil {
89
89
+
return 0, 0, fmt.Errorf("invalid start: %w", err)
90
90
+
}
91
91
+
92
92
+
_, err = fmt.Sscanf(parts[1], "%d", &end)
93
93
+
if err != nil {
94
94
+
return 0, 0, fmt.Errorf("invalid end: %w", err)
95
95
+
}
96
96
+
97
97
+
if start > end {
98
98
+
return 0, 0, fmt.Errorf("start must be <= end")
99
99
+
}
100
100
+
101
101
+
return start, end, nil
102
102
+
}
103
103
+
104
104
+
// Formatting helpers
105
105
+
106
106
+
func formatBytes(bytes int64) string {
107
107
+
const unit = 1000
108
108
+
if bytes < unit {
109
109
+
return fmt.Sprintf("%d B", bytes)
110
110
+
}
111
111
+
div, exp := int64(unit), 0
112
112
+
for n := bytes / unit; n >= unit; n /= unit {
113
113
+
div *= unit
114
114
+
exp++
115
115
+
}
116
116
+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
117
117
+
}
118
118
+
119
119
+
func formatDuration(d time.Duration) string {
120
120
+
if d < time.Minute {
121
121
+
return fmt.Sprintf("%.0f seconds", d.Seconds())
122
122
+
}
123
123
+
if d < time.Hour {
124
124
+
return fmt.Sprintf("%.1f minutes", d.Minutes())
125
125
+
}
126
126
+
if d < 24*time.Hour {
127
127
+
return fmt.Sprintf("%.1f hours", d.Hours())
128
128
+
}
129
129
+
days := d.Hours() / 24
130
130
+
if days < 30 {
131
131
+
return fmt.Sprintf("%.1f days", days)
132
132
+
}
133
133
+
if days < 365 {
134
134
+
return fmt.Sprintf("%.1f months", days/30)
135
135
+
}
136
136
+
return fmt.Sprintf("%.1f years", days/365)
137
137
+
}
138
138
+
139
139
+
func formatNumber(n int) string {
140
140
+
s := fmt.Sprintf("%d", n)
141
141
+
var result []byte
142
142
+
for i, c := range s {
143
143
+
if i > 0 && (len(s)-i)%3 == 0 {
144
144
+
result = append(result, ',')
145
145
+
}
146
146
+
result = append(result, byte(c))
147
147
+
}
148
148
+
return string(result)
149
149
+
}
+466
cmd/plcbundle/commands/compare.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"flag"
5
5
+
"fmt"
6
6
+
"io"
7
7
+
"net/http"
8
8
+
"os"
9
9
+
"path/filepath"
10
10
+
"sort"
11
11
+
"strings"
12
12
+
"time"
13
13
+
14
14
+
"github.com/goccy/go-json"
15
15
+
"tangled.org/atscan.net/plcbundle/internal/bundleindex"
16
16
+
)
17
17
+
18
18
+
// CompareCommand handles the compare subcommand
19
19
+
func CompareCommand(args []string) error {
20
20
+
fs := flag.NewFlagSet("compare", flag.ExitOnError)
21
21
+
verbose := fs.Bool("v", false, "verbose output (show all differences)")
22
22
+
fetchMissing := fs.Bool("fetch-missing", false, "fetch missing bundles from target")
23
23
+
24
24
+
if err := fs.Parse(args); err != nil {
25
25
+
return err
26
26
+
}
27
27
+
28
28
+
if fs.NArg() < 1 {
29
29
+
return fmt.Errorf("usage: plcbundle compare <target> [options]\n" +
30
30
+
" target: URL or path to remote plcbundle server/index\n\n" +
31
31
+
"Examples:\n" +
32
32
+
" plcbundle compare https://plc.example.com\n" +
33
33
+
" plcbundle compare https://plc.example.com/index.json\n" +
34
34
+
" plcbundle compare /path/to/plc_bundles.json\n" +
35
35
+
" plcbundle compare https://plc.example.com --fetch-missing")
36
36
+
}
37
37
+
38
38
+
target := fs.Arg(0)
39
39
+
40
40
+
mgr, dir, err := getManager("")
41
41
+
if err != nil {
42
42
+
return err
43
43
+
}
44
44
+
defer mgr.Close()
45
45
+
46
46
+
fmt.Printf("Comparing: %s\n", dir)
47
47
+
fmt.Printf(" Against: %s\n\n", target)
48
48
+
49
49
+
// Load local index
50
50
+
localIndex := mgr.GetIndex()
51
51
+
52
52
+
// Load target index
53
53
+
fmt.Printf("Loading target index...\n")
54
54
+
targetIndex, err := loadTargetIndex(target)
55
55
+
if err != nil {
56
56
+
return fmt.Errorf("error loading target index: %w", err)
57
57
+
}
58
58
+
59
59
+
// Perform comparison
60
60
+
comparison := compareIndexes(localIndex, targetIndex)
61
61
+
62
62
+
// Display results
63
63
+
displayComparison(comparison, *verbose)
64
64
+
65
65
+
// Fetch missing bundles if requested
66
66
+
if *fetchMissing && len(comparison.MissingBundles) > 0 {
67
67
+
fmt.Printf("\n")
68
68
+
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
69
69
+
return fmt.Errorf("--fetch-missing only works with remote URLs")
70
70
+
}
71
71
+
72
72
+
baseURL := strings.TrimSuffix(target, "/index.json")
73
73
+
baseURL = strings.TrimSuffix(baseURL, "/plc_bundles.json")
74
74
+
75
75
+
fmt.Printf("Fetching %d missing bundles...\n\n", len(comparison.MissingBundles))
76
76
+
if err := fetchMissingBundles(mgr, baseURL, comparison.MissingBundles); err != nil {
77
77
+
return err
78
78
+
}
79
79
+
}
80
80
+
81
81
+
if comparison.HasDifferences() {
82
82
+
return fmt.Errorf("indexes have differences")
83
83
+
}
84
84
+
85
85
+
return nil
86
86
+
}
87
87
+
88
88
+
func loadTargetIndex(target string) (*bundleindex.Index, error) {
89
89
+
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
90
90
+
return loadIndexFromURL(target)
91
91
+
}
92
92
+
return bundleindex.LoadIndex(target)
93
93
+
}
94
94
+
95
95
+
func loadIndexFromURL(url string) (*bundleindex.Index, error) {
96
96
+
if !strings.HasSuffix(url, ".json") {
97
97
+
url = strings.TrimSuffix(url, "/") + "/index.json"
98
98
+
}
99
99
+
100
100
+
client := &http.Client{Timeout: 30 * time.Second}
101
101
+
102
102
+
resp, err := client.Get(url)
103
103
+
if err != nil {
104
104
+
return nil, fmt.Errorf("failed to download: %w", err)
105
105
+
}
106
106
+
defer resp.Body.Close()
107
107
+
108
108
+
if resp.StatusCode != http.StatusOK {
109
109
+
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
110
110
+
}
111
111
+
112
112
+
data, err := io.ReadAll(resp.Body)
113
113
+
if err != nil {
114
114
+
return nil, fmt.Errorf("failed to read response: %w", err)
115
115
+
}
116
116
+
117
117
+
var idx bundleindex.Index
118
118
+
if err := json.Unmarshal(data, &idx); err != nil {
119
119
+
return nil, fmt.Errorf("failed to parse index: %w", err)
120
120
+
}
121
121
+
122
122
+
return &idx, nil
123
123
+
}
124
124
+
125
125
+
func compareIndexes(local, target *bundleindex.Index) *IndexComparison {
126
126
+
localBundles := local.GetBundles()
127
127
+
targetBundles := target.GetBundles()
128
128
+
129
129
+
localMap := make(map[int]*bundleindex.BundleMetadata)
130
130
+
targetMap := make(map[int]*bundleindex.BundleMetadata)
131
131
+
132
132
+
for _, b := range localBundles {
133
133
+
localMap[b.BundleNumber] = b
134
134
+
}
135
135
+
for _, b := range targetBundles {
136
136
+
targetMap[b.BundleNumber] = b
137
137
+
}
138
138
+
139
139
+
comparison := &IndexComparison{
140
140
+
LocalCount: len(localBundles),
141
141
+
TargetCount: len(targetBundles),
142
142
+
MissingBundles: make([]int, 0),
143
143
+
ExtraBundles: make([]int, 0),
144
144
+
HashMismatches: make([]HashMismatch, 0),
145
145
+
ContentMismatches: make([]HashMismatch, 0),
146
146
+
}
147
147
+
148
148
+
// Get ranges
149
149
+
if len(localBundles) > 0 {
150
150
+
comparison.LocalRange = [2]int{localBundles[0].BundleNumber, localBundles[len(localBundles)-1].BundleNumber}
151
151
+
comparison.LocalUpdated = local.UpdatedAt
152
152
+
comparison.LocalTotalSize = local.TotalSize
153
153
+
}
154
154
+
155
155
+
if len(targetBundles) > 0 {
156
156
+
comparison.TargetRange = [2]int{targetBundles[0].BundleNumber, targetBundles[len(targetBundles)-1].BundleNumber}
157
157
+
comparison.TargetUpdated = target.UpdatedAt
158
158
+
comparison.TargetTotalSize = target.TotalSize
159
159
+
}
160
160
+
161
161
+
// Find missing bundles
162
162
+
for bundleNum := range targetMap {
163
163
+
if _, exists := localMap[bundleNum]; !exists {
164
164
+
comparison.MissingBundles = append(comparison.MissingBundles, bundleNum)
165
165
+
}
166
166
+
}
167
167
+
sort.Ints(comparison.MissingBundles)
168
168
+
169
169
+
// Find extra bundles
170
170
+
for bundleNum := range localMap {
171
171
+
if _, exists := targetMap[bundleNum]; !exists {
172
172
+
comparison.ExtraBundles = append(comparison.ExtraBundles, bundleNum)
173
173
+
}
174
174
+
}
175
175
+
sort.Ints(comparison.ExtraBundles)
176
176
+
177
177
+
// Compare hashes
178
178
+
for bundleNum, localMeta := range localMap {
179
179
+
if targetMeta, exists := targetMap[bundleNum]; exists {
180
180
+
comparison.CommonCount++
181
181
+
182
182
+
chainMismatch := localMeta.Hash != targetMeta.Hash
183
183
+
contentMismatch := localMeta.ContentHash != targetMeta.ContentHash
184
184
+
185
185
+
if chainMismatch || contentMismatch {
186
186
+
mismatch := HashMismatch{
187
187
+
BundleNumber: bundleNum,
188
188
+
LocalHash: localMeta.Hash,
189
189
+
TargetHash: targetMeta.Hash,
190
190
+
LocalContentHash: localMeta.ContentHash,
191
191
+
TargetContentHash: targetMeta.ContentHash,
192
192
+
}
193
193
+
194
194
+
if chainMismatch {
195
195
+
comparison.HashMismatches = append(comparison.HashMismatches, mismatch)
196
196
+
}
197
197
+
if contentMismatch && !chainMismatch {
198
198
+
comparison.ContentMismatches = append(comparison.ContentMismatches, mismatch)
199
199
+
}
200
200
+
}
201
201
+
}
202
202
+
}
203
203
+
204
204
+
return comparison
205
205
+
}
206
206
+
207
207
+
func displayComparison(c *IndexComparison, verbose bool) {
208
208
+
fmt.Printf("Comparison Results\n")
209
209
+
fmt.Printf("══════════════════\n\n")
210
210
+
211
211
+
fmt.Printf("Summary\n───────\n")
212
212
+
fmt.Printf(" Local bundles: %d\n", c.LocalCount)
213
213
+
fmt.Printf(" Target bundles: %d\n", c.TargetCount)
214
214
+
fmt.Printf(" Common bundles: %d\n", c.CommonCount)
215
215
+
fmt.Printf(" Missing bundles: %s\n", formatCount(len(c.MissingBundles)))
216
216
+
fmt.Printf(" Extra bundles: %s\n", formatCount(len(c.ExtraBundles)))
217
217
+
fmt.Printf(" Hash mismatches: %s\n", formatCountCritical(len(c.HashMismatches)))
218
218
+
fmt.Printf(" Content mismatches: %s\n", formatCount(len(c.ContentMismatches)))
219
219
+
220
220
+
if c.LocalCount > 0 {
221
221
+
fmt.Printf("\n Local range: %06d - %06d\n", c.LocalRange[0], c.LocalRange[1])
222
222
+
fmt.Printf(" Local size: %.2f MB\n", float64(c.LocalTotalSize)/(1024*1024))
223
223
+
fmt.Printf(" Local updated: %s\n", c.LocalUpdated.Format("2006-01-02 15:04:05"))
224
224
+
}
225
225
+
226
226
+
if c.TargetCount > 0 {
227
227
+
fmt.Printf("\n Target range: %06d - %06d\n", c.TargetRange[0], c.TargetRange[1])
228
228
+
fmt.Printf(" Target size: %.2f MB\n", float64(c.TargetTotalSize)/(1024*1024))
229
229
+
fmt.Printf(" Target updated: %s\n", c.TargetUpdated.Format("2006-01-02 15:04:05"))
230
230
+
}
231
231
+
232
232
+
// Show differences
233
233
+
if len(c.HashMismatches) > 0 {
234
234
+
showHashMismatches(c.HashMismatches, verbose)
235
235
+
}
236
236
+
237
237
+
if len(c.MissingBundles) > 0 {
238
238
+
showMissingBundles(c.MissingBundles, verbose)
239
239
+
}
240
240
+
241
241
+
if len(c.ExtraBundles) > 0 {
242
242
+
showExtraBundles(c.ExtraBundles, verbose)
243
243
+
}
244
244
+
245
245
+
// Final status
246
246
+
fmt.Printf("\n")
247
247
+
if !c.HasDifferences() {
248
248
+
fmt.Printf("✓ Indexes are identical\n")
249
249
+
} else {
250
250
+
fmt.Printf("✗ Indexes have differences\n")
251
251
+
if len(c.HashMismatches) > 0 {
252
252
+
fmt.Printf("\n⚠️ WARNING: Chain hash mismatches detected!\n")
253
253
+
fmt.Printf("This indicates different bundle content or chain integrity issues.\n")
254
254
+
}
255
255
+
}
256
256
+
}
257
257
+
258
258
+
func showHashMismatches(mismatches []HashMismatch, verbose bool) {
259
259
+
fmt.Printf("\n⚠️ CHAIN HASH MISMATCHES (CRITICAL)\n")
260
260
+
fmt.Printf("════════════════════════════════════\n\n")
261
261
+
262
262
+
displayCount := len(mismatches)
263
263
+
if displayCount > 10 && !verbose {
264
264
+
displayCount = 10
265
265
+
}
266
266
+
267
267
+
for i := 0; i < displayCount; i++ {
268
268
+
m := mismatches[i]
269
269
+
fmt.Printf(" Bundle %06d:\n", m.BundleNumber)
270
270
+
fmt.Printf(" Chain Hash:\n")
271
271
+
fmt.Printf(" Local: %s\n", m.LocalHash)
272
272
+
fmt.Printf(" Target: %s\n", m.TargetHash)
273
273
+
274
274
+
if m.LocalContentHash != m.TargetContentHash {
275
275
+
fmt.Printf(" Content Hash (also differs):\n")
276
276
+
fmt.Printf(" Local: %s\n", m.LocalContentHash)
277
277
+
fmt.Printf(" Target: %s\n", m.TargetContentHash)
278
278
+
}
279
279
+
fmt.Printf("\n")
280
280
+
}
281
281
+
282
282
+
if len(mismatches) > displayCount {
283
283
+
fmt.Printf(" ... and %d more (use -v to show all)\n\n", len(mismatches)-displayCount)
284
284
+
}
285
285
+
}
286
286
+
287
287
+
func showMissingBundles(bundles []int, verbose bool) {
288
288
+
fmt.Printf("\nMissing Bundles (in target but not local)\n")
289
289
+
fmt.Printf("──────────────────────────────────────────\n")
290
290
+
291
291
+
if verbose || len(bundles) <= 20 {
292
292
+
displayCount := len(bundles)
293
293
+
if displayCount > 20 && !verbose {
294
294
+
displayCount = 20
295
295
+
}
296
296
+
297
297
+
for i := 0; i < displayCount; i++ {
298
298
+
fmt.Printf(" %06d\n", bundles[i])
299
299
+
}
300
300
+
301
301
+
if len(bundles) > displayCount {
302
302
+
fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount)
303
303
+
}
304
304
+
} else {
305
305
+
displayBundleRanges(bundles)
306
306
+
}
307
307
+
}
308
308
+
309
309
+
func showExtraBundles(bundles []int, verbose bool) {
310
310
+
fmt.Printf("\nExtra Bundles (in local but not target)\n")
311
311
+
fmt.Printf("────────────────────────────────────────\n")
312
312
+
313
313
+
if verbose || len(bundles) <= 20 {
314
314
+
displayCount := len(bundles)
315
315
+
if displayCount > 20 && !verbose {
316
316
+
displayCount = 20
317
317
+
}
318
318
+
319
319
+
for i := 0; i < displayCount; i++ {
320
320
+
fmt.Printf(" %06d\n", bundles[i])
321
321
+
}
322
322
+
323
323
+
if len(bundles) > displayCount {
324
324
+
fmt.Printf(" ... and %d more (use -v to show all)\n", len(bundles)-displayCount)
325
325
+
}
326
326
+
} else {
327
327
+
displayBundleRanges(bundles)
328
328
+
}
329
329
+
}
330
330
+
331
331
+
func displayBundleRanges(bundles []int) {
332
332
+
if len(bundles) == 0 {
333
333
+
return
334
334
+
}
335
335
+
336
336
+
rangeStart := bundles[0]
337
337
+
rangeEnd := bundles[0]
338
338
+
339
339
+
for i := 1; i < len(bundles); i++ {
340
340
+
if bundles[i] == rangeEnd+1 {
341
341
+
rangeEnd = bundles[i]
342
342
+
} else {
343
343
+
if rangeStart == rangeEnd {
344
344
+
fmt.Printf(" %06d\n", rangeStart)
345
345
+
} else {
346
346
+
fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd)
347
347
+
}
348
348
+
rangeStart = bundles[i]
349
349
+
rangeEnd = bundles[i]
350
350
+
}
351
351
+
}
352
352
+
353
353
+
if rangeStart == rangeEnd {
354
354
+
fmt.Printf(" %06d\n", rangeStart)
355
355
+
} else {
356
356
+
fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd)
357
357
+
}
358
358
+
}
359
359
+
360
360
+
func fetchMissingBundles(mgr BundleManager, baseURL string, missingBundles []int) error {
361
361
+
client := &http.Client{Timeout: 60 * time.Second}
362
362
+
363
363
+
successCount := 0
364
364
+
errorCount := 0
365
365
+
366
366
+
info := mgr.GetInfo()
367
367
+
bundleDir := info["bundle_dir"].(string)
368
368
+
369
369
+
for _, bundleNum := range missingBundles {
370
370
+
fmt.Printf("Fetching bundle %06d... ", bundleNum)
371
371
+
372
372
+
url := fmt.Sprintf("%s/data/%d", baseURL, bundleNum)
373
373
+
resp, err := client.Get(url)
374
374
+
if err != nil {
375
375
+
fmt.Printf("ERROR: %v\n", err)
376
376
+
errorCount++
377
377
+
continue
378
378
+
}
379
379
+
380
380
+
if resp.StatusCode != http.StatusOK {
381
381
+
fmt.Printf("ERROR: status %d\n", resp.StatusCode)
382
382
+
resp.Body.Close()
383
383
+
errorCount++
384
384
+
continue
385
385
+
}
386
386
+
387
387
+
filename := fmt.Sprintf("%06d.jsonl.zst", bundleNum)
388
388
+
filepath := filepath.Join(bundleDir, filename)
389
389
+
390
390
+
outFile, err := os.Create(filepath)
391
391
+
if err != nil {
392
392
+
fmt.Printf("ERROR: %v\n", err)
393
393
+
resp.Body.Close()
394
394
+
errorCount++
395
395
+
continue
396
396
+
}
397
397
+
398
398
+
_, err = io.Copy(outFile, resp.Body)
399
399
+
outFile.Close()
400
400
+
resp.Body.Close()
401
401
+
402
402
+
if err != nil {
403
403
+
fmt.Printf("ERROR: %v\n", err)
404
404
+
os.Remove(filepath)
405
405
+
errorCount++
406
406
+
continue
407
407
+
}
408
408
+
409
409
+
fmt.Printf("✓\n")
410
410
+
successCount++
411
411
+
time.Sleep(200 * time.Millisecond)
412
412
+
}
413
413
+
414
414
+
fmt.Printf("\n✓ Fetch complete: %d succeeded, %d failed\n", successCount, errorCount)
415
415
+
416
416
+
if errorCount > 0 {
417
417
+
return fmt.Errorf("some bundles failed to download")
418
418
+
}
419
419
+
420
420
+
return nil
421
421
+
}
422
422
+
423
423
+
// Types
424
424
+
425
425
+
type IndexComparison struct {
426
426
+
LocalCount int
427
427
+
TargetCount int
428
428
+
CommonCount int
429
429
+
MissingBundles []int
430
430
+
ExtraBundles []int
431
431
+
HashMismatches []HashMismatch
432
432
+
ContentMismatches []HashMismatch
433
433
+
LocalRange [2]int
434
434
+
TargetRange [2]int
435
435
+
LocalTotalSize int64
436
436
+
TargetTotalSize int64
437
437
+
LocalUpdated time.Time
438
438
+
TargetUpdated time.Time
439
439
+
}
440
440
+
441
441
+
type HashMismatch struct {
442
442
+
BundleNumber int
443
443
+
LocalHash string
444
444
+
TargetHash string
445
445
+
LocalContentHash string
446
446
+
TargetContentHash string
447
447
+
}
448
448
+
449
449
+
func (ic *IndexComparison) HasDifferences() bool {
450
450
+
return len(ic.MissingBundles) > 0 || len(ic.ExtraBundles) > 0 ||
451
451
+
len(ic.HashMismatches) > 0 || len(ic.ContentMismatches) > 0
452
452
+
}
453
453
+
454
454
+
func formatCount(count int) string {
455
455
+
if count == 0 {
456
456
+
return "\033[32m0 ✓\033[0m"
457
457
+
}
458
458
+
return fmt.Sprintf("\033[33m%d ⚠️\033[0m", count)
459
459
+
}
460
460
+
461
461
+
func formatCountCritical(count int) string {
462
462
+
if count == 0 {
463
463
+
return "\033[32m0 ✓\033[0m"
464
464
+
}
465
465
+
return fmt.Sprintf("\033[31m%d ✗\033[0m", count)
466
466
+
}
+122
cmd/plcbundle/commands/export.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"os"
8
8
+
"time"
9
9
+
10
10
+
"github.com/goccy/go-json"
11
11
+
)
12
12
+
13
13
+
// ExportCommand handles the export subcommand
14
14
+
func ExportCommand(args []string) error {
15
15
+
fs := flag.NewFlagSet("export", flag.ExitOnError)
16
16
+
bundles := fs.String("bundles", "", "bundle number or range (e.g., '42' or '1-100')")
17
17
+
all := fs.Bool("all", false, "export all bundles")
18
18
+
count := fs.Int("count", 0, "limit number of operations (0 = all)")
19
19
+
after := fs.String("after", "", "timestamp to start after (RFC3339)")
20
20
+
21
21
+
if err := fs.Parse(args); err != nil {
22
22
+
return err
23
23
+
}
24
24
+
25
25
+
if !*all && *bundles == "" {
26
26
+
return fmt.Errorf("usage: plcbundle export --bundles <number|range> [options]\n" +
27
27
+
" or: plcbundle export --all [options]\n\n" +
28
28
+
"Examples:\n" +
29
29
+
" plcbundle export --bundles 42\n" +
30
30
+
" plcbundle export --bundles 1-100\n" +
31
31
+
" plcbundle export --all\n" +
32
32
+
" plcbundle export --all --count 50000\n" +
33
33
+
" plcbundle export --bundles 42 | jq .")
34
34
+
}
35
35
+
36
36
+
mgr, _, err := getManager("")
37
37
+
if err != nil {
38
38
+
return err
39
39
+
}
40
40
+
defer mgr.Close()
41
41
+
42
42
+
// Determine bundle range
43
43
+
var start, end int
44
44
+
if *all {
45
45
+
index := mgr.GetIndex()
46
46
+
bundleList := index.GetBundles()
47
47
+
if len(bundleList) == 0 {
48
48
+
return fmt.Errorf("no bundles available")
49
49
+
}
50
50
+
start = bundleList[0].BundleNumber
51
51
+
end = bundleList[len(bundleList)-1].BundleNumber
52
52
+
53
53
+
fmt.Fprintf(os.Stderr, "Exporting all bundles (%d-%d)\n", start, end)
54
54
+
} else {
55
55
+
var err error
56
56
+
start, end, err = parseBundleRange(*bundles)
57
57
+
if err != nil {
58
58
+
return err
59
59
+
}
60
60
+
fmt.Fprintf(os.Stderr, "Exporting bundles %d-%d\n", start, end)
61
61
+
}
62
62
+
63
63
+
if *count > 0 {
64
64
+
fmt.Fprintf(os.Stderr, "Limit: %d operations\n", *count)
65
65
+
}
66
66
+
if *after != "" {
67
67
+
fmt.Fprintf(os.Stderr, "After: %s\n", *after)
68
68
+
}
69
69
+
fmt.Fprintf(os.Stderr, "\n")
70
70
+
71
71
+
// Parse after time
72
72
+
var afterTime time.Time
73
73
+
if *after != "" {
74
74
+
afterTime, err = time.Parse(time.RFC3339, *after)
75
75
+
if err != nil {
76
76
+
return fmt.Errorf("invalid after time: %w", err)
77
77
+
}
78
78
+
}
79
79
+
80
80
+
ctx := context.Background()
81
81
+
exported := 0
82
82
+
83
83
+
// Export operations
84
84
+
for bundleNum := start; bundleNum <= end; bundleNum++ {
85
85
+
if *count > 0 && exported >= *count {
86
86
+
break
87
87
+
}
88
88
+
89
89
+
fmt.Fprintf(os.Stderr, "Processing bundle %d...\r", bundleNum)
90
90
+
91
91
+
bundle, err := mgr.LoadBundle(ctx, bundleNum)
92
92
+
if err != nil {
93
93
+
fmt.Fprintf(os.Stderr, "\nWarning: failed to load bundle %d: %v\n", bundleNum, err)
94
94
+
continue
95
95
+
}
96
96
+
97
97
+
for _, op := range bundle.Operations {
98
98
+
if !afterTime.IsZero() && op.CreatedAt.Before(afterTime) {
99
99
+
continue
100
100
+
}
101
101
+
102
102
+
if *count > 0 && exported >= *count {
103
103
+
break
104
104
+
}
105
105
+
106
106
+
// Output as JSONL
107
107
+
if len(op.RawJSON) > 0 {
108
108
+
fmt.Println(string(op.RawJSON))
109
109
+
} else {
110
110
+
data, _ := json.Marshal(op)
111
111
+
fmt.Println(string(data))
112
112
+
}
113
113
+
114
114
+
exported++
115
115
+
}
116
116
+
}
117
117
+
118
118
+
fmt.Fprintf(os.Stderr, "\n\n✓ Export complete\n")
119
119
+
fmt.Fprintf(os.Stderr, " Exported: %d operations\n", exported)
120
120
+
121
121
+
return nil
122
122
+
}
+108
cmd/plcbundle/commands/fetch.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"os"
8
8
+
"time"
9
9
+
)
10
10
+
11
11
+
// FetchCommand handles the fetch subcommand
12
12
+
func FetchCommand(args []string) error {
13
13
+
fs := flag.NewFlagSet("fetch", flag.ExitOnError)
14
14
+
plcURL := fs.String("plc", "https://plc.directory", "PLC directory URL")
15
15
+
count := fs.Int("count", 0, "number of bundles to fetch (0 = fetch all available)")
16
16
+
verbose := fs.Bool("verbose", false, "verbose sync logging")
17
17
+
18
18
+
if err := fs.Parse(args); err != nil {
19
19
+
return err
20
20
+
}
21
21
+
22
22
+
mgr, dir, err := getManager(*plcURL)
23
23
+
if err != nil {
24
24
+
return err
25
25
+
}
26
26
+
defer mgr.Close()
27
27
+
28
28
+
fmt.Printf("Working in: %s\n", dir)
29
29
+
30
30
+
ctx := context.Background()
31
31
+
32
32
+
// Get starting bundle info
33
33
+
index := mgr.GetIndex()
34
34
+
lastBundle := index.GetLastBundle()
35
35
+
startBundle := 1
36
36
+
if lastBundle != nil {
37
37
+
startBundle = lastBundle.BundleNumber + 1
38
38
+
}
39
39
+
40
40
+
fmt.Printf("Starting from bundle %06d\n", startBundle)
41
41
+
42
42
+
if *count > 0 {
43
43
+
fmt.Printf("Fetching %d bundles...\n", *count)
44
44
+
} else {
45
45
+
fmt.Printf("Fetching all available bundles...\n")
46
46
+
}
47
47
+
48
48
+
fetchedCount := 0
49
49
+
consecutiveErrors := 0
50
50
+
maxConsecutiveErrors := 3
51
51
+
52
52
+
for {
53
53
+
// Check if we've reached the requested count
54
54
+
if *count > 0 && fetchedCount >= *count {
55
55
+
break
56
56
+
}
57
57
+
58
58
+
currentBundle := startBundle + fetchedCount
59
59
+
60
60
+
if *count > 0 {
61
61
+
fmt.Printf("Fetching bundle %d/%d (bundle %06d)...\n", fetchedCount+1, *count, currentBundle)
62
62
+
} else {
63
63
+
fmt.Printf("Fetching bundle %06d...\n", currentBundle)
64
64
+
}
65
65
+
66
66
+
b, err := mgr.FetchNextBundle(ctx, !*verbose)
67
67
+
if err != nil {
68
68
+
// Check if we've reached the end
69
69
+
if isEndOfDataError(err) {
70
70
+
fmt.Printf("\n✓ Caught up! No more complete bundles available.\n")
71
71
+
fmt.Printf(" Last bundle: %06d\n", currentBundle-1)
72
72
+
break
73
73
+
}
74
74
+
75
75
+
// Handle other errors
76
76
+
consecutiveErrors++
77
77
+
fmt.Fprintf(os.Stderr, "Error fetching bundle %06d: %v\n", currentBundle, err)
78
78
+
79
79
+
if consecutiveErrors >= maxConsecutiveErrors {
80
80
+
return fmt.Errorf("too many consecutive errors, stopping")
81
81
+
}
82
82
+
83
83
+
fmt.Printf("Waiting 5 seconds before retry...\n")
84
84
+
time.Sleep(5 * time.Second)
85
85
+
continue
86
86
+
}
87
87
+
88
88
+
// Reset error counter on success
89
89
+
consecutiveErrors = 0
90
90
+
91
91
+
if err := mgr.SaveBundle(ctx, b, !*verbose); err != nil {
92
92
+
return fmt.Errorf("error saving bundle %06d: %w", b.BundleNumber, err)
93
93
+
}
94
94
+
95
95
+
fetchedCount++
96
96
+
fmt.Printf("✓ Saved bundle %06d (%d operations, %d DIDs)\n",
97
97
+
b.BundleNumber, len(b.Operations), b.DIDCount)
98
98
+
}
99
99
+
100
100
+
if fetchedCount > 0 {
101
101
+
fmt.Printf("\n✓ Fetch complete: %d bundles retrieved\n", fetchedCount)
102
102
+
fmt.Printf(" Current range: %06d - %06d\n", startBundle, startBundle+fetchedCount-1)
103
103
+
} else {
104
104
+
fmt.Printf("\n✓ Already up to date!\n")
105
105
+
}
106
106
+
107
107
+
return nil
108
108
+
}
+49
cmd/plcbundle/commands/getop.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"strconv"
7
7
+
8
8
+
"github.com/goccy/go-json"
9
9
+
)
10
10
+
11
11
+
// GetOpCommand handles the get-op subcommand
12
12
+
func GetOpCommand(args []string) error {
13
13
+
if len(args) < 2 {
14
14
+
return fmt.Errorf("usage: plcbundle get-op <bundle> <position>\n" +
15
15
+
"Example: plcbundle get-op 42 1337")
16
16
+
}
17
17
+
18
18
+
bundleNum, err := strconv.Atoi(args[0])
19
19
+
if err != nil {
20
20
+
return fmt.Errorf("invalid bundle number")
21
21
+
}
22
22
+
23
23
+
position, err := strconv.Atoi(args[1])
24
24
+
if err != nil {
25
25
+
return fmt.Errorf("invalid position")
26
26
+
}
27
27
+
28
28
+
mgr, _, err := getManager("")
29
29
+
if err != nil {
30
30
+
return err
31
31
+
}
32
32
+
defer mgr.Close()
33
33
+
34
34
+
ctx := context.Background()
35
35
+
op, err := mgr.LoadOperation(ctx, bundleNum, position)
36
36
+
if err != nil {
37
37
+
return err
38
38
+
}
39
39
+
40
40
+
// Output JSON
41
41
+
if len(op.RawJSON) > 0 {
42
42
+
fmt.Println(string(op.RawJSON))
43
43
+
} else {
44
44
+
data, _ := json.Marshal(op)
45
45
+
fmt.Println(string(data))
46
46
+
}
47
47
+
48
48
+
return nil
49
49
+
}
+431
cmd/plcbundle/commands/index.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"os"
8
8
+
"strings"
9
9
+
"time"
10
10
+
11
11
+
"github.com/goccy/go-json"
12
12
+
"tangled.org/atscan.net/plcbundle/cmd/plcbundle/ui"
13
13
+
"tangled.org/atscan.net/plcbundle/plcclient"
14
14
+
)
15
15
+
16
16
+
// IndexCommand handles the index subcommand
17
17
+
func IndexCommand(args []string) error {
18
18
+
if len(args) < 1 {
19
19
+
printIndexUsage()
20
20
+
return fmt.Errorf("subcommand required")
21
21
+
}
22
22
+
23
23
+
subcommand := args[0]
24
24
+
25
25
+
switch subcommand {
26
26
+
case "build":
27
27
+
return indexBuild(args[1:])
28
28
+
case "stats":
29
29
+
return indexStats(args[1:])
30
30
+
case "lookup":
31
31
+
return indexLookup(args[1:])
32
32
+
case "resolve":
33
33
+
return indexResolve(args[1:])
34
34
+
default:
35
35
+
printIndexUsage()
36
36
+
return fmt.Errorf("unknown index subcommand: %s", subcommand)
37
37
+
}
38
38
+
}
39
39
+
40
40
+
func printIndexUsage() {
41
41
+
fmt.Printf(`Usage: plcbundle index <command> [options]
42
42
+
43
43
+
Commands:
44
44
+
build Build DID index from bundles
45
45
+
stats Show index statistics
46
46
+
lookup Lookup a specific DID
47
47
+
resolve Resolve DID to current document
48
48
+
49
49
+
Examples:
50
50
+
plcbundle index build
51
51
+
plcbundle index stats
52
52
+
plcbundle index lookup did:plc:524tuhdhh3m7li5gycdn6boe
53
53
+
plcbundle index resolve did:plc:524tuhdhh3m7li5gycdn6boe
54
54
+
`)
55
55
+
}
56
56
+
57
57
+
func indexBuild(args []string) error {
58
58
+
fs := flag.NewFlagSet("index build", flag.ExitOnError)
59
59
+
force := fs.Bool("force", false, "rebuild even if index exists")
60
60
+
61
61
+
if err := fs.Parse(args); err != nil {
62
62
+
return err
63
63
+
}
64
64
+
65
65
+
mgr, dir, err := getManager("")
66
66
+
if err != nil {
67
67
+
return err
68
68
+
}
69
69
+
defer mgr.Close()
70
70
+
71
71
+
stats := mgr.GetDIDIndexStats()
72
72
+
if stats["exists"].(bool) && !*force {
73
73
+
fmt.Printf("DID index already exists (use --force to rebuild)\n")
74
74
+
fmt.Printf("Directory: %s\n", dir)
75
75
+
fmt.Printf("Total DIDs: %d\n", stats["total_dids"])
76
76
+
return nil
77
77
+
}
78
78
+
79
79
+
fmt.Printf("Building DID index in: %s\n", dir)
80
80
+
81
81
+
index := mgr.GetIndex()
82
82
+
bundleCount := index.Count()
83
83
+
84
84
+
if bundleCount == 0 {
85
85
+
fmt.Printf("No bundles to index\n")
86
86
+
return nil
87
87
+
}
88
88
+
89
89
+
fmt.Printf("Indexing %d bundles...\n\n", bundleCount)
90
90
+
91
91
+
progress := ui.NewProgressBar(bundleCount)
92
92
+
start := time.Now()
93
93
+
ctx := context.Background()
94
94
+
95
95
+
err = mgr.BuildDIDIndex(ctx, func(current, total int) {
96
96
+
progress.Set(current)
97
97
+
})
98
98
+
99
99
+
progress.Finish()
100
100
+
101
101
+
if err != nil {
102
102
+
return fmt.Errorf("error building index: %w", err)
103
103
+
}
104
104
+
105
105
+
elapsed := time.Since(start)
106
106
+
stats = mgr.GetDIDIndexStats()
107
107
+
108
108
+
fmt.Printf("\n✓ DID index built in %s\n", elapsed.Round(time.Millisecond))
109
109
+
fmt.Printf(" Total DIDs: %s\n", formatNumber(int(stats["total_dids"].(int64))))
110
110
+
fmt.Printf(" Shards: %d\n", stats["shard_count"])
111
111
+
fmt.Printf(" Location: %s/.plcbundle/\n", dir)
112
112
+
113
113
+
return nil
114
114
+
}
115
115
+
116
116
+
func indexStats(args []string) error {
117
117
+
mgr, dir, err := getManager("")
118
118
+
if err != nil {
119
119
+
return err
120
120
+
}
121
121
+
defer mgr.Close()
122
122
+
123
123
+
stats := mgr.GetDIDIndexStats()
124
124
+
125
125
+
if !stats["exists"].(bool) {
126
126
+
fmt.Printf("DID index does not exist\n")
127
127
+
fmt.Printf("Run: plcbundle index build\n")
128
128
+
return nil
129
129
+
}
130
130
+
131
131
+
indexedDIDs := stats["indexed_dids"].(int64)
132
132
+
mempoolDIDs := stats["mempool_dids"].(int64)
133
133
+
totalDIDs := stats["total_dids"].(int64)
134
134
+
135
135
+
fmt.Printf("\nDID Index Statistics\n")
136
136
+
fmt.Printf("════════════════════\n\n")
137
137
+
fmt.Printf(" Location: %s/.plcbundle/\n", dir)
138
138
+
139
139
+
if mempoolDIDs > 0 {
140
140
+
fmt.Printf(" Indexed DIDs: %s (in bundles)\n", formatNumber(int(indexedDIDs)))
141
141
+
fmt.Printf(" Mempool DIDs: %s (not yet bundled)\n", formatNumber(int(mempoolDIDs)))
142
142
+
fmt.Printf(" Total DIDs: %s\n", formatNumber(int(totalDIDs)))
143
143
+
} else {
144
144
+
fmt.Printf(" Total DIDs: %s\n", formatNumber(int(totalDIDs)))
145
145
+
}
146
146
+
147
147
+
fmt.Printf(" Shard count: %d\n", stats["shard_count"])
148
148
+
fmt.Printf(" Last bundle: %06d\n", stats["last_bundle"])
149
149
+
fmt.Printf(" Updated: %s\n\n", stats["updated_at"].(time.Time).Format("2006-01-02 15:04:05"))
150
150
+
fmt.Printf(" Cached shards: %d / %d\n", stats["cached_shards"], stats["cache_limit"])
151
151
+
152
152
+
if cachedList, ok := stats["cache_order"].([]int); ok && len(cachedList) > 0 {
153
153
+
fmt.Printf(" Hot shards: ")
154
154
+
for i, shard := range cachedList {
155
155
+
if i > 0 {
156
156
+
fmt.Printf(", ")
157
157
+
}
158
158
+
if i >= 10 {
159
159
+
fmt.Printf("... (+%d more)", len(cachedList)-10)
160
160
+
break
161
161
+
}
162
162
+
fmt.Printf("%02x", shard)
163
163
+
}
164
164
+
fmt.Printf("\n")
165
165
+
}
166
166
+
167
167
+
fmt.Printf("\n")
168
168
+
return nil
169
169
+
}
170
170
+
171
171
+
func indexLookup(args []string) error {
172
172
+
fs := flag.NewFlagSet("index lookup", flag.ExitOnError)
173
173
+
verbose := fs.Bool("v", false, "verbose debug output")
174
174
+
showJSON := fs.Bool("json", false, "output as JSON")
175
175
+
176
176
+
if err := fs.Parse(args); err != nil {
177
177
+
return err
178
178
+
}
179
179
+
180
180
+
if fs.NArg() < 1 {
181
181
+
return fmt.Errorf("usage: plcbundle index lookup <did> [-v] [--json]")
182
182
+
}
183
183
+
184
184
+
did := fs.Arg(0)
185
185
+
186
186
+
mgr, _, err := getManager("")
187
187
+
if err != nil {
188
188
+
return err
189
189
+
}
190
190
+
defer mgr.Close()
191
191
+
192
192
+
stats := mgr.GetDIDIndexStats()
193
193
+
if !stats["exists"].(bool) {
194
194
+
fmt.Fprintf(os.Stderr, "⚠️ DID index does not exist. Run: plcbundle index build\n")
195
195
+
fmt.Fprintf(os.Stderr, " Falling back to full scan (this will be slow)...\n\n")
196
196
+
}
197
197
+
198
198
+
if !*showJSON {
199
199
+
fmt.Printf("Looking up: %s\n", did)
200
200
+
if *verbose {
201
201
+
fmt.Printf("Verbose mode: enabled\n")
202
202
+
}
203
203
+
fmt.Printf("\n")
204
204
+
}
205
205
+
206
206
+
totalStart := time.Now()
207
207
+
ctx := context.Background()
208
208
+
209
209
+
// Lookup operations
210
210
+
lookupStart := time.Now()
211
211
+
opsWithLoc, err := mgr.GetDIDOperationsWithLocations(ctx, did, *verbose)
212
212
+
if err != nil {
213
213
+
return err
214
214
+
}
215
215
+
lookupElapsed := time.Since(lookupStart)
216
216
+
217
217
+
// Check mempool
218
218
+
mempoolStart := time.Now()
219
219
+
mempoolOps, err := mgr.GetDIDOperationsFromMempool(did)
220
220
+
if err != nil {
221
221
+
return fmt.Errorf("error checking mempool: %w", err)
222
222
+
}
223
223
+
mempoolElapsed := time.Since(mempoolStart)
224
224
+
225
225
+
totalElapsed := time.Since(totalStart)
226
226
+
227
227
+
if len(opsWithLoc) == 0 && len(mempoolOps) == 0 {
228
228
+
if *showJSON {
229
229
+
fmt.Println("{\"found\": false, \"operations\": []}")
230
230
+
} else {
231
231
+
fmt.Printf("DID not found (searched in %s)\n", totalElapsed)
232
232
+
}
233
233
+
return nil
234
234
+
}
235
235
+
236
236
+
if *showJSON {
237
237
+
return outputLookupJSON(did, opsWithLoc, mempoolOps, totalElapsed, lookupElapsed, mempoolElapsed)
238
238
+
}
239
239
+
240
240
+
return displayLookupResults(did, opsWithLoc, mempoolOps, totalElapsed, lookupElapsed, mempoolElapsed, *verbose, stats)
241
241
+
}
242
242
+
243
243
+
func indexResolve(args []string) error {
244
244
+
fs := flag.NewFlagSet("index resolve", flag.ExitOnError)
245
245
+
246
246
+
if err := fs.Parse(args); err != nil {
247
247
+
return err
248
248
+
}
249
249
+
250
250
+
if fs.NArg() < 1 {
251
251
+
return fmt.Errorf("usage: plcbundle index resolve <did>")
252
252
+
}
253
253
+
254
254
+
did := fs.Arg(0)
255
255
+
256
256
+
mgr, _, err := getManager("")
257
257
+
if err != nil {
258
258
+
return err
259
259
+
}
260
260
+
defer mgr.Close()
261
261
+
262
262
+
ctx := context.Background()
263
263
+
fmt.Fprintf(os.Stderr, "Resolving: %s\n", did)
264
264
+
265
265
+
start := time.Now()
266
266
+
267
267
+
// Check mempool first
268
268
+
mempoolOps, _ := mgr.GetDIDOperationsFromMempool(did)
269
269
+
if len(mempoolOps) > 0 {
270
270
+
for i := len(mempoolOps) - 1; i >= 0; i-- {
271
271
+
if !mempoolOps[i].IsNullified() {
272
272
+
doc, err := plcclient.ResolveDIDDocument(did, []plcclient.PLCOperation{mempoolOps[i]})
273
273
+
if err != nil {
274
274
+
return fmt.Errorf("resolution failed: %w", err)
275
275
+
}
276
276
+
277
277
+
totalTime := time.Since(start)
278
278
+
fmt.Fprintf(os.Stderr, "Total: %s (resolved from mempool)\n\n", totalTime)
279
279
+
280
280
+
data, _ := json.MarshalIndent(doc, "", " ")
281
281
+
fmt.Println(string(data))
282
282
+
return nil
283
283
+
}
284
284
+
}
285
285
+
}
286
286
+
287
287
+
// Use index
288
288
+
op, err := mgr.GetLatestDIDOperation(ctx, did)
289
289
+
if err != nil {
290
290
+
return fmt.Errorf("failed to get latest operation: %w", err)
291
291
+
}
292
292
+
293
293
+
doc, err := plcclient.ResolveDIDDocument(did, []plcclient.PLCOperation{*op})
294
294
+
if err != nil {
295
295
+
return fmt.Errorf("resolution failed: %w", err)
296
296
+
}
297
297
+
298
298
+
totalTime := time.Since(start)
299
299
+
fmt.Fprintf(os.Stderr, "Total: %s\n\n", totalTime)
300
300
+
301
301
+
data, _ := json.MarshalIndent(doc, "", " ")
302
302
+
fmt.Println(string(data))
303
303
+
304
304
+
return nil
305
305
+
}
306
306
+
307
307
+
func outputLookupJSON(did string, opsWithLoc []PLCOperationWithLocation, mempoolOps []plcclient.PLCOperation, totalElapsed, lookupElapsed, mempoolElapsed time.Duration) error {
308
308
+
output := map[string]interface{}{
309
309
+
"found": true,
310
310
+
"did": did,
311
311
+
"timing": map[string]interface{}{
312
312
+
"total_ms": totalElapsed.Milliseconds(),
313
313
+
"lookup_ms": lookupElapsed.Milliseconds(),
314
314
+
"mempool_ms": mempoolElapsed.Milliseconds(),
315
315
+
},
316
316
+
"bundled": make([]map[string]interface{}, 0),
317
317
+
"mempool": make([]map[string]interface{}, 0),
318
318
+
}
319
319
+
320
320
+
for _, owl := range opsWithLoc {
321
321
+
output["bundled"] = append(output["bundled"].([]map[string]interface{}), map[string]interface{}{
322
322
+
"bundle": owl.Bundle,
323
323
+
"position": owl.Position,
324
324
+
"cid": owl.Operation.CID,
325
325
+
"nullified": owl.Operation.IsNullified(),
326
326
+
"created_at": owl.Operation.CreatedAt.Format(time.RFC3339Nano),
327
327
+
})
328
328
+
}
329
329
+
330
330
+
for _, op := range mempoolOps {
331
331
+
output["mempool"] = append(output["mempool"].([]map[string]interface{}), map[string]interface{}{
332
332
+
"cid": op.CID,
333
333
+
"nullified": op.IsNullified(),
334
334
+
"created_at": op.CreatedAt.Format(time.RFC3339Nano),
335
335
+
})
336
336
+
}
337
337
+
338
338
+
data, _ := json.MarshalIndent(output, "", " ")
339
339
+
fmt.Println(string(data))
340
340
+
341
341
+
return nil
342
342
+
}
343
343
+
344
344
+
func displayLookupResults(did string, opsWithLoc []PLCOperationWithLocation, mempoolOps []plcclient.PLCOperation, totalElapsed, lookupElapsed, mempoolElapsed time.Duration, verbose bool, stats map[string]interface{}) error {
345
345
+
nullifiedCount := 0
346
346
+
for _, owl := range opsWithLoc {
347
347
+
if owl.Operation.IsNullified() {
348
348
+
nullifiedCount++
349
349
+
}
350
350
+
}
351
351
+
352
352
+
totalOps := len(opsWithLoc) + len(mempoolOps)
353
353
+
activeOps := len(opsWithLoc) - nullifiedCount + len(mempoolOps)
354
354
+
355
355
+
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
356
356
+
fmt.Printf(" DID Lookup Results\n")
357
357
+
fmt.Printf("═══════════════════════════════════════════════════════════════\n\n")
358
358
+
fmt.Printf("DID: %s\n\n", did)
359
359
+
360
360
+
fmt.Printf("Summary\n───────\n")
361
361
+
fmt.Printf(" Total operations: %d\n", totalOps)
362
362
+
fmt.Printf(" Active operations: %d\n", activeOps)
363
363
+
if nullifiedCount > 0 {
364
364
+
fmt.Printf(" Nullified: %d\n", nullifiedCount)
365
365
+
}
366
366
+
if len(opsWithLoc) > 0 {
367
367
+
fmt.Printf(" Bundled: %d\n", len(opsWithLoc))
368
368
+
}
369
369
+
if len(mempoolOps) > 0 {
370
370
+
fmt.Printf(" Mempool: %d\n", len(mempoolOps))
371
371
+
}
372
372
+
fmt.Printf("\n")
373
373
+
374
374
+
fmt.Printf("Performance\n───────────\n")
375
375
+
fmt.Printf(" Index lookup: %s\n", lookupElapsed)
376
376
+
fmt.Printf(" Mempool check: %s\n", mempoolElapsed)
377
377
+
fmt.Printf(" Total time: %s\n\n", totalElapsed)
378
378
+
379
379
+
// Show operations
380
380
+
if len(opsWithLoc) > 0 {
381
381
+
fmt.Printf("Bundled Operations (%d total)\n", len(opsWithLoc))
382
382
+
fmt.Printf("══════════════════════════════════════════════════════════════\n\n")
383
383
+
384
384
+
for i, owl := range opsWithLoc {
385
385
+
op := owl.Operation
386
386
+
status := "✓ Active"
387
387
+
if op.IsNullified() {
388
388
+
status = "✗ Nullified"
389
389
+
}
390
390
+
391
391
+
fmt.Printf("Operation %d [Bundle %06d, Position %04d]\n", i+1, owl.Bundle, owl.Position)
392
392
+
fmt.Printf(" CID: %s\n", op.CID)
393
393
+
fmt.Printf(" Created: %s\n", op.CreatedAt.Format("2006-01-02 15:04:05.000 MST"))
394
394
+
fmt.Printf(" Status: %s\n", status)
395
395
+
396
396
+
if verbose && !op.IsNullified() {
397
397
+
showOperationDetails(&op)
398
398
+
}
399
399
+
400
400
+
fmt.Printf("\n")
401
401
+
}
402
402
+
}
403
403
+
404
404
+
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
405
405
+
fmt.Printf("✓ Lookup complete in %s\n", totalElapsed)
406
406
+
if stats["exists"].(bool) {
407
407
+
fmt.Printf(" Method: DID index (fast)\n")
408
408
+
} else {
409
409
+
fmt.Printf(" Method: Full scan (slow)\n")
410
410
+
}
411
411
+
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
412
412
+
413
413
+
return nil
414
414
+
}
415
415
+
416
416
+
func showOperationDetails(op *plcclient.PLCOperation) {
417
417
+
if opData, err := op.GetOperationData(); err == nil && opData != nil {
418
418
+
if opType, ok := opData["type"].(string); ok {
419
419
+
fmt.Printf(" Type: %s\n", opType)
420
420
+
}
421
421
+
422
422
+
if handle, ok := opData["handle"].(string); ok {
423
423
+
fmt.Printf(" Handle: %s\n", handle)
424
424
+
} else if aka, ok := opData["alsoKnownAs"].([]interface{}); ok && len(aka) > 0 {
425
425
+
if akaStr, ok := aka[0].(string); ok {
426
426
+
handle := strings.TrimPrefix(akaStr, "at://")
427
427
+
fmt.Printf(" Handle: %s\n", handle)
428
428
+
}
429
429
+
}
430
430
+
}
431
431
+
}
+194
cmd/plcbundle/commands/mempool.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"flag"
5
5
+
"fmt"
6
6
+
"os"
7
7
+
"path/filepath"
8
8
+
"strings"
9
9
+
"time"
10
10
+
11
11
+
"tangled.org/atscan.net/plcbundle/internal/types"
12
12
+
)
13
13
+
14
14
+
// MempoolCommand handles the mempool subcommand
15
15
+
func MempoolCommand(args []string) error {
16
16
+
fs := flag.NewFlagSet("mempool", flag.ExitOnError)
17
17
+
clear := fs.Bool("clear", false, "clear the mempool")
18
18
+
export := fs.Bool("export", false, "export mempool operations as JSONL to stdout")
19
19
+
refresh := fs.Bool("refresh", false, "reload mempool from disk")
20
20
+
validate := fs.Bool("validate", false, "validate chronological order")
21
21
+
verbose := fs.Bool("v", false, "verbose output")
22
22
+
23
23
+
if err := fs.Parse(args); err != nil {
24
24
+
return err
25
25
+
}
26
26
+
27
27
+
mgr, dir, err := getManager("")
28
28
+
if err != nil {
29
29
+
return err
30
30
+
}
31
31
+
defer mgr.Close()
32
32
+
33
33
+
fmt.Printf("Working in: %s\n\n", dir)
34
34
+
35
35
+
// Handle validate
36
36
+
if *validate {
37
37
+
fmt.Printf("Validating mempool chronological order...\n")
38
38
+
if err := mgr.ValidateMempool(); err != nil {
39
39
+
return fmt.Errorf("validation failed: %w", err)
40
40
+
}
41
41
+
fmt.Printf("✓ Mempool validation passed\n")
42
42
+
return nil
43
43
+
}
44
44
+
45
45
+
// Handle refresh
46
46
+
if *refresh {
47
47
+
fmt.Printf("Refreshing mempool from disk...\n")
48
48
+
if err := mgr.RefreshMempool(); err != nil {
49
49
+
return fmt.Errorf("refresh failed: %w", err)
50
50
+
}
51
51
+
52
52
+
if err := mgr.ValidateMempool(); err != nil {
53
53
+
fmt.Fprintf(os.Stderr, "⚠️ Warning: mempool validation failed after refresh: %v\n", err)
54
54
+
} else {
55
55
+
fmt.Printf("✓ Mempool refreshed and validated\n\n")
56
56
+
}
57
57
+
}
58
58
+
59
59
+
// Handle clear
60
60
+
if *clear {
61
61
+
stats := mgr.GetMempoolStats()
62
62
+
count := stats["count"].(int)
63
63
+
64
64
+
if count == 0 {
65
65
+
fmt.Println("Mempool is already empty")
66
66
+
return nil
67
67
+
}
68
68
+
69
69
+
fmt.Printf("⚠️ This will clear %d operations from the mempool.\n", count)
70
70
+
fmt.Printf("Are you sure? [y/N]: ")
71
71
+
var response string
72
72
+
fmt.Scanln(&response)
73
73
+
if strings.ToLower(strings.TrimSpace(response)) != "y" {
74
74
+
fmt.Println("Cancelled")
75
75
+
return nil
76
76
+
}
77
77
+
78
78
+
if err := mgr.ClearMempool(); err != nil {
79
79
+
return fmt.Errorf("clear failed: %w", err)
80
80
+
}
81
81
+
82
82
+
fmt.Printf("✓ Mempool cleared (%d operations removed)\n", count)
83
83
+
return nil
84
84
+
}
85
85
+
86
86
+
// Handle export
87
87
+
if *export {
88
88
+
ops, err := mgr.GetMempoolOperations()
89
89
+
if err != nil {
90
90
+
return fmt.Errorf("failed to get mempool operations: %w", err)
91
91
+
}
92
92
+
93
93
+
if len(ops) == 0 {
94
94
+
fmt.Fprintf(os.Stderr, "Mempool is empty\n")
95
95
+
return nil
96
96
+
}
97
97
+
98
98
+
for _, op := range ops {
99
99
+
if len(op.RawJSON) > 0 {
100
100
+
fmt.Println(string(op.RawJSON))
101
101
+
}
102
102
+
}
103
103
+
104
104
+
fmt.Fprintf(os.Stderr, "Exported %d operations from mempool\n", len(ops))
105
105
+
return nil
106
106
+
}
107
107
+
108
108
+
// Default: Show mempool stats
109
109
+
return showMempoolStats(mgr, dir, *verbose)
110
110
+
}
111
111
+
112
112
+
func showMempoolStats(mgr BundleManager, dir string, verbose bool) error {
113
113
+
stats := mgr.GetMempoolStats()
114
114
+
count := stats["count"].(int)
115
115
+
canCreate := stats["can_create_bundle"].(bool)
116
116
+
targetBundle := stats["target_bundle"].(int)
117
117
+
minTimestamp := stats["min_timestamp"].(time.Time)
118
118
+
validated := stats["validated"].(bool)
119
119
+
120
120
+
fmt.Printf("Mempool Status:\n")
121
121
+
fmt.Printf(" Target bundle: %06d\n", targetBundle)
122
122
+
fmt.Printf(" Operations: %d\n", count)
123
123
+
fmt.Printf(" Can create bundle: %v (need %d)\n", canCreate, types.BUNDLE_SIZE)
124
124
+
fmt.Printf(" Min timestamp: %s\n", minTimestamp.Format("2006-01-02 15:04:05"))
125
125
+
126
126
+
validationIcon := "✓"
127
127
+
if !validated {
128
128
+
validationIcon = "⚠️"
129
129
+
}
130
130
+
fmt.Printf(" Validated: %s %v\n", validationIcon, validated)
131
131
+
132
132
+
if count > 0 {
133
133
+
if sizeBytes, ok := stats["size_bytes"].(int); ok {
134
134
+
fmt.Printf(" Size: %.2f KB\n", float64(sizeBytes)/1024)
135
135
+
}
136
136
+
137
137
+
if firstTime, ok := stats["first_time"].(time.Time); ok {
138
138
+
fmt.Printf(" First operation: %s\n", firstTime.Format("2006-01-02 15:04:05"))
139
139
+
}
140
140
+
141
141
+
if lastTime, ok := stats["last_time"].(time.Time); ok {
142
142
+
fmt.Printf(" Last operation: %s\n", lastTime.Format("2006-01-02 15:04:05"))
143
143
+
}
144
144
+
145
145
+
progress := float64(count) / float64(types.BUNDLE_SIZE) * 100
146
146
+
fmt.Printf(" Progress: %.1f%% (%d/%d)\n", progress, count, types.BUNDLE_SIZE)
147
147
+
148
148
+
// Progress bar
149
149
+
barWidth := 40
150
150
+
filled := int(float64(barWidth) * float64(count) / float64(types.BUNDLE_SIZE))
151
151
+
if filled > barWidth {
152
152
+
filled = barWidth
153
153
+
}
154
154
+
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
155
155
+
fmt.Printf(" [%s]\n", bar)
156
156
+
} else {
157
157
+
fmt.Printf(" (empty)\n")
158
158
+
}
159
159
+
160
160
+
// Verbose: Show sample operations
161
161
+
if verbose && count > 0 {
162
162
+
fmt.Println()
163
163
+
fmt.Printf("Sample operations (showing up to 10):\n")
164
164
+
165
165
+
ops, err := mgr.GetMempoolOperations()
166
166
+
if err != nil {
167
167
+
return fmt.Errorf("error getting operations: %w", err)
168
168
+
}
169
169
+
170
170
+
showCount := 10
171
171
+
if len(ops) < showCount {
172
172
+
showCount = len(ops)
173
173
+
}
174
174
+
175
175
+
for i := 0; i < showCount; i++ {
176
176
+
op := ops[i]
177
177
+
fmt.Printf(" %d. DID: %s\n", i+1, op.DID)
178
178
+
fmt.Printf(" CID: %s\n", op.CID)
179
179
+
fmt.Printf(" Created: %s\n", op.CreatedAt.Format("2006-01-02 15:04:05.000"))
180
180
+
}
181
181
+
182
182
+
if len(ops) > showCount {
183
183
+
fmt.Printf(" ... and %d more\n", len(ops)-showCount)
184
184
+
}
185
185
+
}
186
186
+
187
187
+
fmt.Println()
188
188
+
189
189
+
// Show mempool file
190
190
+
mempoolFilename := fmt.Sprintf("plc_mempool_%06d.jsonl", targetBundle)
191
191
+
fmt.Printf("File: %s\n", filepath.Join(dir, mempoolFilename))
192
192
+
193
193
+
return nil
194
194
+
}
+165
cmd/plcbundle/commands/rebuild.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"os"
8
8
+
"path/filepath"
9
9
+
"runtime"
10
10
+
"time"
11
11
+
12
12
+
"tangled.org/atscan.net/plcbundle/cmd/plcbundle/ui"
13
13
+
"tangled.org/atscan.net/plcbundle/internal/bundle"
14
14
+
"tangled.org/atscan.net/plcbundle/internal/bundleindex"
15
15
+
)
16
16
+
17
17
+
// RebuildCommand handles the rebuild subcommand
18
18
+
func RebuildCommand(args []string) error {
19
19
+
fs := flag.NewFlagSet("rebuild", flag.ExitOnError)
20
20
+
verbose := fs.Bool("v", false, "verbose output")
21
21
+
workers := fs.Int("workers", 0, "number of parallel workers (0 = CPU count)")
22
22
+
noProgress := fs.Bool("no-progress", false, "disable progress bar")
23
23
+
24
24
+
if err := fs.Parse(args); err != nil {
25
25
+
return err
26
26
+
}
27
27
+
28
28
+
// Auto-detect CPU count
29
29
+
if *workers == 0 {
30
30
+
*workers = runtime.NumCPU()
31
31
+
}
32
32
+
33
33
+
// Get working directory
34
34
+
dir, err := os.Getwd()
35
35
+
if err != nil {
36
36
+
return err
37
37
+
}
38
38
+
39
39
+
if err := os.MkdirAll(dir, 0755); err != nil {
40
40
+
return err
41
41
+
}
42
42
+
43
43
+
// Create manager WITHOUT auto-rebuild
44
44
+
config := bundle.DefaultConfig(dir)
45
45
+
config.AutoRebuild = false
46
46
+
config.RebuildWorkers = *workers
47
47
+
48
48
+
mgr, err := bundle.NewManager(config, nil)
49
49
+
if err != nil {
50
50
+
return err
51
51
+
}
52
52
+
defer mgr.Close()
53
53
+
54
54
+
fmt.Printf("Rebuilding index from: %s\n", dir)
55
55
+
fmt.Printf("Using %d workers\n", *workers)
56
56
+
57
57
+
// Find all bundle files
58
58
+
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl.zst"))
59
59
+
if err != nil {
60
60
+
return fmt.Errorf("error scanning directory: %w", err)
61
61
+
}
62
62
+
63
63
+
// Filter out hidden/temp files
64
64
+
files = filterBundleFiles(files)
65
65
+
66
66
+
if len(files) == 0 {
67
67
+
fmt.Println("No bundle files found")
68
68
+
return nil
69
69
+
}
70
70
+
71
71
+
fmt.Printf("Found %d bundle files\n\n", len(files))
72
72
+
73
73
+
start := time.Now()
74
74
+
75
75
+
// Create progress bar
76
76
+
var progress *ui.ProgressBar
77
77
+
var progressCallback func(int, int, int64)
78
78
+
79
79
+
if !*noProgress {
80
80
+
fmt.Println("Processing bundles:")
81
81
+
progress = ui.NewProgressBar(len(files))
82
82
+
83
83
+
progressCallback = func(current, total int, bytesProcessed int64) {
84
84
+
progress.SetWithBytes(current, bytesProcessed)
85
85
+
}
86
86
+
}
87
87
+
88
88
+
// Use parallel scan
89
89
+
result, err := mgr.ScanDirectoryParallel(*workers, progressCallback)
90
90
+
91
91
+
if err != nil {
92
92
+
if progress != nil {
93
93
+
progress.Finish()
94
94
+
}
95
95
+
return fmt.Errorf("rebuild failed: %w", err)
96
96
+
}
97
97
+
98
98
+
if progress != nil {
99
99
+
progress.Finish()
100
100
+
}
101
101
+
102
102
+
elapsed := time.Since(start)
103
103
+
104
104
+
fmt.Printf("\n✓ Index rebuilt in %s\n", elapsed.Round(time.Millisecond))
105
105
+
fmt.Printf(" Total bundles: %d\n", result.BundleCount)
106
106
+
fmt.Printf(" Compressed size: %s\n", formatBytes(result.TotalSize))
107
107
+
fmt.Printf(" Uncompressed size: %s\n", formatBytes(result.TotalUncompressed))
108
108
+
109
109
+
if result.TotalUncompressed > 0 {
110
110
+
ratio := float64(result.TotalUncompressed) / float64(result.TotalSize)
111
111
+
fmt.Printf(" Compression ratio: %.2fx\n", ratio)
112
112
+
}
113
113
+
114
114
+
fmt.Printf(" Average speed: %.1f bundles/sec\n", float64(result.BundleCount)/elapsed.Seconds())
115
115
+
116
116
+
if elapsed.Seconds() > 0 {
117
117
+
compressedThroughput := float64(result.TotalSize) / elapsed.Seconds() / (1000 * 1000)
118
118
+
uncompressedThroughput := float64(result.TotalUncompressed) / elapsed.Seconds() / (1000 * 1000)
119
119
+
fmt.Printf(" Throughput (compressed): %.1f MB/s\n", compressedThroughput)
120
120
+
fmt.Printf(" Throughput (uncompressed): %.1f MB/s\n", uncompressedThroughput)
121
121
+
}
122
122
+
123
123
+
fmt.Printf(" Index file: %s\n", filepath.Join(dir, bundleindex.INDEX_FILE))
124
124
+
125
125
+
if len(result.MissingGaps) > 0 {
126
126
+
fmt.Printf(" ⚠️ Missing gaps: %d bundles\n", len(result.MissingGaps))
127
127
+
}
128
128
+
129
129
+
// Verify chain if verbose
130
130
+
if *verbose {
131
131
+
fmt.Printf("\nVerifying chain integrity...\n")
132
132
+
133
133
+
ctx := context.Background()
134
134
+
verifyResult, err := mgr.VerifyChain(ctx)
135
135
+
if err != nil {
136
136
+
fmt.Printf(" ⚠️ Verification error: %v\n", err)
137
137
+
} else if verifyResult.Valid {
138
138
+
fmt.Printf(" ✓ Chain is valid (%d bundles verified)\n", len(verifyResult.VerifiedBundles))
139
139
+
140
140
+
// Show head hash
141
141
+
index := mgr.GetIndex()
142
142
+
if lastMeta := index.GetLastBundle(); lastMeta != nil {
143
143
+
fmt.Printf(" Chain head: %s...\n", lastMeta.Hash[:16])
144
144
+
}
145
145
+
} else {
146
146
+
fmt.Printf(" ✗ Chain verification failed\n")
147
147
+
fmt.Printf(" Broken at: bundle %06d\n", verifyResult.BrokenAt)
148
148
+
fmt.Printf(" Error: %s\n", verifyResult.Error)
149
149
+
}
150
150
+
}
151
151
+
152
152
+
return nil
153
153
+
}
154
154
+
155
155
+
func filterBundleFiles(files []string) []string {
156
156
+
filtered := make([]string, 0, len(files))
157
157
+
for _, file := range files {
158
158
+
basename := filepath.Base(file)
159
159
+
if len(basename) > 0 && (basename[0] == '.' || basename[0] == '_') {
160
160
+
continue
161
161
+
}
162
162
+
filtered = append(filtered, file)
163
163
+
}
164
164
+
return filtered
165
165
+
}
+416
cmd/plcbundle/commands/server.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
"os"
8
8
+
"os/signal"
9
9
+
"runtime"
10
10
+
"syscall"
11
11
+
"time"
12
12
+
13
13
+
"tangled.org/atscan.net/plcbundle/cmd/plcbundle/ui"
14
14
+
"tangled.org/atscan.net/plcbundle/internal/bundle"
15
15
+
"tangled.org/atscan.net/plcbundle/internal/didindex"
16
16
+
"tangled.org/atscan.net/plcbundle/plcclient"
17
17
+
"tangled.org/atscan.net/plcbundle/server"
18
18
+
)
19
19
+
20
20
+
// ServerCommand handles the serve subcommand
21
21
+
func ServerCommand(args []string) error {
22
22
+
fs := flag.NewFlagSet("serve", flag.ExitOnError)
23
23
+
port := fs.String("port", "8080", "HTTP server port")
24
24
+
host := fs.String("host", "127.0.0.1", "HTTP server host")
25
25
+
syncMode := fs.Bool("sync", false, "enable sync mode (auto-sync from PLC)")
26
26
+
plcURL := fs.String("plc", "https://plc.directory", "PLC directory URL (for sync mode)")
27
27
+
syncInterval := fs.Duration("sync-interval", 1*time.Minute, "sync interval for sync mode")
28
28
+
enableWebSocket := fs.Bool("websocket", false, "enable WebSocket endpoint for streaming")
29
29
+
enableResolver := fs.Bool("resolver", false, "enable DID resolution endpoints")
30
30
+
workers := fs.Int("workers", 0, "number of workers for auto-rebuild (0 = CPU count)")
31
31
+
verbose := fs.Bool("verbose", false, "verbose sync logging")
32
32
+
33
33
+
if err := fs.Parse(args); err != nil {
34
34
+
return err
35
35
+
}
36
36
+
37
37
+
// Auto-detect CPU count
38
38
+
if *workers == 0 {
39
39
+
*workers = runtime.NumCPU()
40
40
+
}
41
41
+
42
42
+
// Get working directory
43
43
+
dir, err := os.Getwd()
44
44
+
if err != nil {
45
45
+
return fmt.Errorf("failed to get working directory: %w", err)
46
46
+
}
47
47
+
48
48
+
if err := os.MkdirAll(dir, 0755); err != nil {
49
49
+
return fmt.Errorf("failed to create directory: %w", err)
50
50
+
}
51
51
+
52
52
+
// Create manager config
53
53
+
config := bundle.DefaultConfig(dir)
54
54
+
config.RebuildWorkers = *workers
55
55
+
config.RebuildProgress = func(current, total int) {
56
56
+
if current%100 == 0 || current == total {
57
57
+
fmt.Printf(" Rebuild progress: %d/%d bundles (%.1f%%) \r",
58
58
+
current, total, float64(current)/float64(total)*100)
59
59
+
if current == total {
60
60
+
fmt.Println()
61
61
+
}
62
62
+
}
63
63
+
}
64
64
+
65
65
+
// Create PLC client if sync mode enabled
66
66
+
var client *plcclient.Client
67
67
+
if *syncMode {
68
68
+
client = plcclient.NewClient(*plcURL)
69
69
+
}
70
70
+
71
71
+
fmt.Printf("Starting plcbundle HTTP server...\n")
72
72
+
fmt.Printf(" Directory: %s\n", dir)
73
73
+
74
74
+
// Create manager
75
75
+
mgr, err := bundle.NewManager(config, client)
76
76
+
if err != nil {
77
77
+
return fmt.Errorf("failed to create manager: %w", err)
78
78
+
}
79
79
+
defer mgr.Close()
80
80
+
81
81
+
// Build/verify DID index if resolver enabled
82
82
+
if *enableResolver {
83
83
+
if err := ensureDIDIndex(mgr, *verbose); err != nil {
84
84
+
fmt.Fprintf(os.Stderr, "⚠️ DID index warning: %v\n\n", err)
85
85
+
}
86
86
+
}
87
87
+
88
88
+
addr := fmt.Sprintf("%s:%s", *host, *port)
89
89
+
90
90
+
// Display server info
91
91
+
displayServerInfo(mgr, addr, *syncMode, *enableWebSocket, *enableResolver, *plcURL, *syncInterval)
92
92
+
93
93
+
// Setup graceful shutdown
94
94
+
ctx, cancel := setupGracefulShutdown(mgr)
95
95
+
defer cancel()
96
96
+
97
97
+
// Start sync loop if enabled
98
98
+
if *syncMode {
99
99
+
go runSyncLoop(ctx, mgr, *syncInterval, *verbose, *enableResolver)
100
100
+
}
101
101
+
102
102
+
// Create and start HTTP server
103
103
+
serverConfig := &server.Config{
104
104
+
Addr: addr,
105
105
+
SyncMode: *syncMode,
106
106
+
SyncInterval: *syncInterval,
107
107
+
EnableWebSocket: *enableWebSocket,
108
108
+
EnableResolver: *enableResolver,
109
109
+
Version: GetVersion(), // Pass version
110
110
+
}
111
111
+
112
112
+
srv := server.New(mgr, serverConfig)
113
113
+
114
114
+
if err := srv.ListenAndServe(); err != nil {
115
115
+
return fmt.Errorf("server error: %w", err)
116
116
+
}
117
117
+
118
118
+
return nil
119
119
+
}
120
120
+
121
121
+
// ensureDIDIndex builds DID index if needed
122
122
+
func ensureDIDIndex(mgr *bundle.Manager, verbose bool) error {
123
123
+
index := mgr.GetIndex()
124
124
+
bundleCount := index.Count()
125
125
+
didStats := mgr.GetDIDIndexStats()
126
126
+
127
127
+
if bundleCount == 0 {
128
128
+
return nil
129
129
+
}
130
130
+
131
131
+
needsBuild := false
132
132
+
reason := ""
133
133
+
134
134
+
if !didStats["exists"].(bool) {
135
135
+
needsBuild = true
136
136
+
reason = "index does not exist"
137
137
+
} else {
138
138
+
// Check version
139
139
+
didIndex := mgr.GetDIDIndex()
140
140
+
if didIndex != nil {
141
141
+
config := didIndex.GetConfig()
142
142
+
if config.Version != didindex.DIDINDEX_VERSION {
143
143
+
needsBuild = true
144
144
+
reason = fmt.Sprintf("index version outdated (v%d, need v%d)",
145
145
+
config.Version, didindex.DIDINDEX_VERSION)
146
146
+
} else {
147
147
+
// Check if index is behind bundles
148
148
+
lastBundle := index.GetLastBundle()
149
149
+
if lastBundle != nil && config.LastBundle < lastBundle.BundleNumber {
150
150
+
needsBuild = true
151
151
+
reason = fmt.Sprintf("index is behind (bundle %d, need %d)",
152
152
+
config.LastBundle, lastBundle.BundleNumber)
153
153
+
}
154
154
+
}
155
155
+
}
156
156
+
}
157
157
+
158
158
+
if needsBuild {
159
159
+
fmt.Printf(" DID Index: BUILDING (%s)\n", reason)
160
160
+
fmt.Printf(" This may take several minutes...\n\n")
161
161
+
162
162
+
buildStart := time.Now()
163
163
+
ctx := context.Background()
164
164
+
165
165
+
progress := ui.NewProgressBar(bundleCount)
166
166
+
err := mgr.BuildDIDIndex(ctx, func(current, total int) {
167
167
+
progress.Set(current)
168
168
+
})
169
169
+
progress.Finish()
170
170
+
171
171
+
if err != nil {
172
172
+
return fmt.Errorf("failed to build DID index: %w", err)
173
173
+
}
174
174
+
175
175
+
buildTime := time.Since(buildStart)
176
176
+
updatedStats := mgr.GetDIDIndexStats()
177
177
+
fmt.Printf("\n✓ DID index built in %s\n", buildTime.Round(time.Millisecond))
178
178
+
fmt.Printf(" Total DIDs: %s\n\n", formatNumber(int(updatedStats["total_dids"].(int64))))
179
179
+
} else {
180
180
+
fmt.Printf(" DID Index: ready (%s DIDs)\n",
181
181
+
formatNumber(int(didStats["total_dids"].(int64))))
182
182
+
}
183
183
+
184
184
+
// Verify index consistency
185
185
+
if didStats["exists"].(bool) {
186
186
+
fmt.Printf(" Verifying index consistency...\n")
187
187
+
188
188
+
ctx := context.Background()
189
189
+
if err := mgr.GetDIDIndex().VerifyAndRepairIndex(ctx, mgr); err != nil {
190
190
+
return fmt.Errorf("index verification/repair failed: %w", err)
191
191
+
}
192
192
+
fmt.Printf(" ✓ Index verified\n")
193
193
+
}
194
194
+
195
195
+
return nil
196
196
+
}
197
197
+
198
198
+
// setupGracefulShutdown sets up signal handling for graceful shutdown
199
199
+
func setupGracefulShutdown(mgr *bundle.Manager) (context.Context, context.CancelFunc) {
200
200
+
ctx, cancel := context.WithCancel(context.Background())
201
201
+
202
202
+
sigChan := make(chan os.Signal, 1)
203
203
+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
204
204
+
205
205
+
go func() {
206
206
+
<-sigChan
207
207
+
fmt.Fprintf(os.Stderr, "\n\n⚠️ Shutdown signal received...\n")
208
208
+
fmt.Fprintf(os.Stderr, " Saving mempool...\n")
209
209
+
210
210
+
if err := mgr.SaveMempool(); err != nil {
211
211
+
fmt.Fprintf(os.Stderr, " ✗ Failed to save mempool: %v\n", err)
212
212
+
} else {
213
213
+
fmt.Fprintf(os.Stderr, " ✓ Mempool saved\n")
214
214
+
}
215
215
+
216
216
+
fmt.Fprintf(os.Stderr, " Closing DID index...\n")
217
217
+
if err := mgr.GetDIDIndex().Close(); err != nil {
218
218
+
fmt.Fprintf(os.Stderr, " ✗ Failed to close index: %v\n", err)
219
219
+
} else {
220
220
+
fmt.Fprintf(os.Stderr, " ✓ Index closed\n")
221
221
+
}
222
222
+
223
223
+
fmt.Fprintf(os.Stderr, " ✓ Shutdown complete\n")
224
224
+
225
225
+
cancel()
226
226
+
os.Exit(0)
227
227
+
}()
228
228
+
229
229
+
return ctx, cancel
230
230
+
}
231
231
+
232
232
+
// displayServerInfo shows server configuration
233
233
+
func displayServerInfo(mgr *bundle.Manager, addr string, syncMode, wsEnabled, resolverEnabled bool, plcURL string, syncInterval time.Duration) {
234
234
+
fmt.Printf(" Listening: http://%s\n", addr)
235
235
+
236
236
+
if syncMode {
237
237
+
fmt.Printf(" Sync mode: ENABLED\n")
238
238
+
fmt.Printf(" PLC URL: %s\n", plcURL)
239
239
+
fmt.Printf(" Sync interval: %s\n", syncInterval)
240
240
+
} else {
241
241
+
fmt.Printf(" Sync mode: disabled\n")
242
242
+
}
243
243
+
244
244
+
if wsEnabled {
245
245
+
wsScheme := "ws"
246
246
+
fmt.Printf(" WebSocket: ENABLED (%s://%s/ws)\n", wsScheme, addr)
247
247
+
} else {
248
248
+
fmt.Printf(" WebSocket: disabled (use --websocket to enable)\n")
249
249
+
}
250
250
+
251
251
+
if resolverEnabled {
252
252
+
fmt.Printf(" Resolver: ENABLED (/<did> endpoints)\n")
253
253
+
} else {
254
254
+
fmt.Printf(" Resolver: disabled (use --resolver to enable)\n")
255
255
+
}
256
256
+
257
257
+
bundleCount := mgr.GetIndex().Count()
258
258
+
if bundleCount > 0 {
259
259
+
fmt.Printf(" Bundles available: %d\n", bundleCount)
260
260
+
} else {
261
261
+
fmt.Printf(" Bundles available: 0\n")
262
262
+
}
263
263
+
264
264
+
fmt.Printf("\nPress Ctrl+C to stop\n\n")
265
265
+
}
266
266
+
267
267
+
// runSyncLoop runs the background sync loop
268
268
+
func runSyncLoop(ctx context.Context, mgr *bundle.Manager, interval time.Duration, verbose bool, resolverEnabled bool) {
269
269
+
// Initial sync
270
270
+
syncBundles(ctx, mgr, verbose, resolverEnabled)
271
271
+
272
272
+
fmt.Fprintf(os.Stderr, "[Sync] Starting sync loop (interval: %s)\n", interval)
273
273
+
274
274
+
ticker := time.NewTicker(interval)
275
275
+
defer ticker.Stop()
276
276
+
277
277
+
saveTicker := time.NewTicker(5 * time.Minute)
278
278
+
defer saveTicker.Stop()
279
279
+
280
280
+
for {
281
281
+
select {
282
282
+
case <-ctx.Done():
283
283
+
if err := mgr.SaveMempool(); err != nil {
284
284
+
fmt.Fprintf(os.Stderr, "[Sync] Failed to save mempool: %v\n", err)
285
285
+
}
286
286
+
fmt.Fprintf(os.Stderr, "[Sync] Stopped\n")
287
287
+
return
288
288
+
289
289
+
case <-ticker.C:
290
290
+
syncBundles(ctx, mgr, verbose, resolverEnabled)
291
291
+
292
292
+
case <-saveTicker.C:
293
293
+
stats := mgr.GetMempoolStats()
294
294
+
if stats["count"].(int) > 0 && verbose {
295
295
+
fmt.Fprintf(os.Stderr, "[Sync] Saving mempool (%d ops)\n", stats["count"])
296
296
+
mgr.SaveMempool()
297
297
+
}
298
298
+
}
299
299
+
}
300
300
+
}
301
301
+
302
302
+
// syncBundles performs a sync cycle
303
303
+
func syncBundles(ctx context.Context, mgr *bundle.Manager, verbose bool, resolverEnabled bool) {
304
304
+
cycleStart := time.Now()
305
305
+
306
306
+
index := mgr.GetIndex()
307
307
+
lastBundle := index.GetLastBundle()
308
308
+
startBundle := 1
309
309
+
if lastBundle != nil {
310
310
+
startBundle = lastBundle.BundleNumber + 1
311
311
+
}
312
312
+
313
313
+
isInitialSync := (lastBundle == nil || lastBundle.BundleNumber < 10)
314
314
+
315
315
+
if isInitialSync && !verbose {
316
316
+
fmt.Fprintf(os.Stderr, "[Sync] Initial sync - fast loading mode (bundle %06d → ...)\n", startBundle)
317
317
+
} else if verbose {
318
318
+
fmt.Fprintf(os.Stderr, "[Sync] Checking for new bundles (current: %06d)...\n", startBundle-1)
319
319
+
}
320
320
+
321
321
+
mempoolBefore := mgr.GetMempoolStats()["count"].(int)
322
322
+
fetchedCount := 0
323
323
+
consecutiveErrors := 0
324
324
+
325
325
+
for {
326
326
+
currentBundle := startBundle + fetchedCount
327
327
+
328
328
+
b, err := mgr.FetchNextBundle(ctx, !verbose)
329
329
+
if err != nil {
330
330
+
if isEndOfDataError(err) {
331
331
+
mempoolAfter := mgr.GetMempoolStats()["count"].(int)
332
332
+
addedOps := mempoolAfter - mempoolBefore
333
333
+
duration := time.Since(cycleStart)
334
334
+
335
335
+
if fetchedCount > 0 {
336
336
+
fmt.Fprintf(os.Stderr, "[Sync] ✓ Bundle %06d | Synced: %d | Mempool: %d (+%d) | %dms\n",
337
337
+
currentBundle-1, fetchedCount, mempoolAfter, addedOps, duration.Milliseconds())
338
338
+
} else if !isInitialSync {
339
339
+
fmt.Fprintf(os.Stderr, "[Sync] ✓ Bundle %06d | Up to date | Mempool: %d (+%d) | %dms\n",
340
340
+
startBundle-1, mempoolAfter, addedOps, duration.Milliseconds())
341
341
+
}
342
342
+
break
343
343
+
}
344
344
+
345
345
+
consecutiveErrors++
346
346
+
if verbose {
347
347
+
fmt.Fprintf(os.Stderr, "[Sync] Error fetching bundle %06d: %v\n", currentBundle, err)
348
348
+
}
349
349
+
350
350
+
if consecutiveErrors >= 3 {
351
351
+
fmt.Fprintf(os.Stderr, "[Sync] Too many errors, stopping\n")
352
352
+
break
353
353
+
}
354
354
+
355
355
+
time.Sleep(5 * time.Second)
356
356
+
continue
357
357
+
}
358
358
+
359
359
+
consecutiveErrors = 0
360
360
+
361
361
+
if err := mgr.SaveBundle(ctx, b, !verbose); err != nil {
362
362
+
fmt.Fprintf(os.Stderr, "[Sync] Error saving bundle %06d: %v\n", b.BundleNumber, err)
363
363
+
break
364
364
+
}
365
365
+
366
366
+
fetchedCount++
367
367
+
368
368
+
if !verbose {
369
369
+
fmt.Fprintf(os.Stderr, "[Sync] ✓ %06d | hash=%s | content=%s | %d ops, %d DIDs\n",
370
370
+
b.BundleNumber,
371
371
+
b.Hash[:16]+"...",
372
372
+
b.ContentHash[:16]+"...",
373
373
+
len(b.Operations),
374
374
+
b.DIDCount)
375
375
+
}
376
376
+
377
377
+
time.Sleep(500 * time.Millisecond)
378
378
+
}
379
379
+
}
380
380
+
381
381
+
// isEndOfDataError checks if error indicates end of available data
382
382
+
func isEndOfDataError(err error) bool {
383
383
+
if err == nil {
384
384
+
return false
385
385
+
}
386
386
+
387
387
+
errMsg := err.Error()
388
388
+
return containsAny(errMsg,
389
389
+
"insufficient operations",
390
390
+
"no more operations available",
391
391
+
"reached latest data")
392
392
+
}
393
393
+
394
394
+
// Helper functions
395
395
+
396
396
+
func containsAny(s string, substrs ...string) bool {
397
397
+
for _, substr := range substrs {
398
398
+
if contains(s, substr) {
399
399
+
return true
400
400
+
}
401
401
+
}
402
402
+
return false
403
403
+
}
404
404
+
405
405
+
func contains(s, substr string) bool {
406
406
+
return len(s) >= len(substr) && indexOf(s, substr) >= 0
407
407
+
}
408
408
+
409
409
+
func indexOf(s, substr string) int {
410
410
+
for i := 0; i <= len(s)-len(substr); i++ {
411
411
+
if s[i:i+len(substr)] == substr {
412
412
+
return i
413
413
+
}
414
414
+
}
415
415
+
return -1
416
416
+
}
+153
cmd/plcbundle/commands/verify.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"flag"
6
6
+
"fmt"
7
7
+
8
8
+
"tangled.org/atscan.net/plcbundle/internal/bundle"
9
9
+
)
10
10
+
11
11
+
// VerifyCommand handles the verify subcommand
12
12
+
func VerifyCommand(args []string) error {
13
13
+
fs := flag.NewFlagSet("verify", flag.ExitOnError)
14
14
+
bundleNum := fs.Int("bundle", 0, "specific bundle to verify (0 = verify chain)")
15
15
+
verbose := fs.Bool("v", false, "verbose output")
16
16
+
17
17
+
if err := fs.Parse(args); err != nil {
18
18
+
return err
19
19
+
}
20
20
+
21
21
+
mgr, dir, err := getManager("")
22
22
+
if err != nil {
23
23
+
return err
24
24
+
}
25
25
+
defer mgr.Close()
26
26
+
27
27
+
fmt.Printf("Working in: %s\n", dir)
28
28
+
29
29
+
ctx := context.Background()
30
30
+
31
31
+
if *bundleNum > 0 {
32
32
+
return verifySingleBundle(ctx, mgr, *bundleNum, *verbose)
33
33
+
}
34
34
+
35
35
+
return verifyChain(ctx, mgr, *verbose)
36
36
+
}
37
37
+
38
38
+
func verifySingleBundle(ctx context.Context, mgr *bundle.Manager, bundleNum int, verbose bool) error {
39
39
+
fmt.Printf("Verifying bundle %06d...\n", bundleNum)
40
40
+
41
41
+
result, err := mgr.VerifyBundle(ctx, bundleNum)
42
42
+
if err != nil {
43
43
+
return fmt.Errorf("verification failed: %w", err)
44
44
+
}
45
45
+
46
46
+
if result.Valid {
47
47
+
fmt.Printf("✓ Bundle %06d is valid\n", bundleNum)
48
48
+
if verbose {
49
49
+
fmt.Printf(" File exists: %v\n", result.FileExists)
50
50
+
fmt.Printf(" Hash match: %v\n", result.HashMatch)
51
51
+
fmt.Printf(" Hash: %s...\n", result.LocalHash[:16])
52
52
+
}
53
53
+
return nil
54
54
+
}
55
55
+
56
56
+
fmt.Printf("✗ Bundle %06d is invalid\n", bundleNum)
57
57
+
if result.Error != nil {
58
58
+
fmt.Printf(" Error: %v\n", result.Error)
59
59
+
}
60
60
+
if !result.FileExists {
61
61
+
fmt.Printf(" File not found\n")
62
62
+
}
63
63
+
if !result.HashMatch && result.FileExists {
64
64
+
fmt.Printf(" Expected hash: %s...\n", result.ExpectedHash[:16])
65
65
+
fmt.Printf(" Actual hash: %s...\n", result.LocalHash[:16])
66
66
+
}
67
67
+
return fmt.Errorf("bundle verification failed")
68
68
+
}
69
69
+
70
70
+
func verifyChain(ctx context.Context, mgr *bundle.Manager, verbose bool) error {
71
71
+
index := mgr.GetIndex()
72
72
+
bundles := index.GetBundles()
73
73
+
74
74
+
if len(bundles) == 0 {
75
75
+
fmt.Println("No bundles to verify")
76
76
+
return nil
77
77
+
}
78
78
+
79
79
+
fmt.Printf("Verifying chain of %d bundles...\n\n", len(bundles))
80
80
+
81
81
+
verifiedCount := 0
82
82
+
errorCount := 0
83
83
+
lastPercent := -1
84
84
+
85
85
+
for i, meta := range bundles {
86
86
+
bundleNum := meta.BundleNumber
87
87
+
88
88
+
percent := (i * 100) / len(bundles)
89
89
+
if percent != lastPercent || verbose {
90
90
+
if verbose {
91
91
+
fmt.Printf(" [%3d%%] Verifying bundle %06d...", percent, bundleNum)
92
92
+
} else if percent%10 == 0 && percent != lastPercent {
93
93
+
fmt.Printf(" [%3d%%] Verified %d/%d bundles...\n", percent, i, len(bundles))
94
94
+
}
95
95
+
lastPercent = percent
96
96
+
}
97
97
+
98
98
+
result, err := mgr.VerifyBundle(ctx, bundleNum)
99
99
+
if err != nil {
100
100
+
if verbose {
101
101
+
fmt.Printf(" ERROR\n")
102
102
+
}
103
103
+
fmt.Printf("\n✗ Failed to verify bundle %06d: %v\n", bundleNum, err)
104
104
+
errorCount++
105
105
+
continue
106
106
+
}
107
107
+
108
108
+
if !result.Valid {
109
109
+
if verbose {
110
110
+
fmt.Printf(" INVALID\n")
111
111
+
}
112
112
+
fmt.Printf("\n✗ Bundle %06d hash verification failed\n", bundleNum)
113
113
+
if result.Error != nil {
114
114
+
fmt.Printf(" Error: %v\n", result.Error)
115
115
+
}
116
116
+
errorCount++
117
117
+
continue
118
118
+
}
119
119
+
120
120
+
if i > 0 {
121
121
+
prevMeta := bundles[i-1]
122
122
+
if meta.Parent != prevMeta.Hash {
123
123
+
if verbose {
124
124
+
fmt.Printf(" CHAIN BROKEN\n")
125
125
+
}
126
126
+
fmt.Printf("\n✗ Chain broken at bundle %06d\n", bundleNum)
127
127
+
fmt.Printf(" Expected parent: %s...\n", prevMeta.Hash[:16])
128
128
+
fmt.Printf(" Actual parent: %s...\n", meta.Parent[:16])
129
129
+
errorCount++
130
130
+
continue
131
131
+
}
132
132
+
}
133
133
+
134
134
+
if verbose {
135
135
+
fmt.Printf(" ✓\n")
136
136
+
}
137
137
+
verifiedCount++
138
138
+
}
139
139
+
140
140
+
fmt.Println()
141
141
+
if errorCount == 0 {
142
142
+
fmt.Printf("✓ Chain is valid (%d bundles verified)\n", verifiedCount)
143
143
+
fmt.Printf(" First bundle: %06d\n", bundles[0].BundleNumber)
144
144
+
fmt.Printf(" Last bundle: %06d\n", bundles[len(bundles)-1].BundleNumber)
145
145
+
fmt.Printf(" Chain head: %s...\n", bundles[len(bundles)-1].Hash[:16])
146
146
+
return nil
147
147
+
}
148
148
+
149
149
+
fmt.Printf("✗ Chain verification failed\n")
150
150
+
fmt.Printf(" Verified: %d/%d bundles\n", verifiedCount, len(bundles))
151
151
+
fmt.Printf(" Errors: %d\n", errorCount)
152
152
+
return fmt.Errorf("chain verification failed")
153
153
+
}
+49
cmd/plcbundle/commands/version.go
···
1
1
+
package commands
2
2
+
3
3
+
import (
4
4
+
"fmt"
5
5
+
"runtime/debug"
6
6
+
)
7
7
+
8
8
+
var (
9
9
+
version = "dev"
10
10
+
gitCommit = "unknown"
11
11
+
buildDate = "unknown"
12
12
+
)
13
13
+
14
14
+
func init() {
15
15
+
if info, ok := debug.ReadBuildInfo(); ok {
16
16
+
if info.Main.Version != "" && info.Main.Version != "(devel)" {
17
17
+
version = info.Main.Version
18
18
+
}
19
19
+
20
20
+
for _, setting := range info.Settings {
21
21
+
switch setting.Key {
22
22
+
case "vcs.revision":
23
23
+
if setting.Value != "" {
24
24
+
gitCommit = setting.Value
25
25
+
if len(gitCommit) > 7 {
26
26
+
gitCommit = gitCommit[:7]
27
27
+
}
28
28
+
}
29
29
+
case "vcs.time":
30
30
+
if setting.Value != "" {
31
31
+
buildDate = setting.Value
32
32
+
}
33
33
+
}
34
34
+
}
35
35
+
}
36
36
+
}
37
37
+
38
38
+
// VersionCommand handles the version subcommand
39
39
+
func VersionCommand(args []string) error {
40
40
+
fmt.Printf("plcbundle version %s\n", version)
41
41
+
fmt.Printf(" commit: %s\n", gitCommit)
42
42
+
fmt.Printf(" built: %s\n", buildDate)
43
43
+
return nil
44
44
+
}
45
45
+
46
46
+
// GetVersion returns the version string
47
47
+
func GetVersion() string {
48
48
+
return version
49
49
+
}
-460
cmd/plcbundle/compare.go
···
1
1
-
package main
2
2
-
3
3
-
import (
4
4
-
"fmt"
5
5
-
"io"
6
6
-
"net/http"
7
7
-
"os"
8
8
-
"path/filepath"
9
9
-
"sort"
10
10
-
"strings"
11
11
-
"time"
12
12
-
13
13
-
"github.com/goccy/go-json"
14
14
-
"tangled.org/atscan.net/plcbundle/internal/bundle"
15
15
-
"tangled.org/atscan.net/plcbundle/internal/bundleindex"
16
16
-
)
17
17
-
18
18
-
// IndexComparison holds comparison results
19
19
-
type IndexComparison struct {
20
20
-
LocalCount int
21
21
-
TargetCount int
22
22
-
CommonCount int
23
23
-
MissingBundles []int // In target but not in local
24
24
-
ExtraBundles []int // In local but not in target
25
25
-
HashMismatches []HashMismatch
26
26
-
ContentMismatches []HashMismatch
27
27
-
LocalRange [2]int
28
28
-
TargetRange [2]int
29
29
-
LocalTotalSize int64
30
30
-
TargetTotalSize int64
31
31
-
LocalUpdated time.Time
32
32
-
TargetUpdated time.Time
33
33
-
}
34
34
-
35
35
-
type HashMismatch struct {
36
36
-
BundleNumber int
37
37
-
LocalHash string // Chain hash
38
38
-
TargetHash string // Chain hash
39
39
-
LocalContentHash string // Content hash
40
40
-
TargetContentHash string // Content hash
41
41
-
}
42
42
-
43
43
-
func (ic *IndexComparison) HasDifferences() bool {
44
44
-
return len(ic.MissingBundles) > 0 || len(ic.ExtraBundles) > 0 ||
45
45
-
len(ic.HashMismatches) > 0 || len(ic.ContentMismatches) > 0
46
46
-
}
47
47
-
48
48
-
// loadTargetIndex loads an index from a file or URL
49
49
-
func loadTargetIndex(target string) (*bundleindex.Index, error) {
50
50
-
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
51
51
-
// Load from URL
52
52
-
return loadIndexFromURL(target)
53
53
-
}
54
54
-
55
55
-
// Load from file
56
56
-
return bundleindex.LoadIndex(target)
57
57
-
}
58
58
-
59
59
-
// loadIndexFromURL downloads and parses an index from a URL
60
60
-
func loadIndexFromURL(url string) (*bundleindex.Index, error) {
61
61
-
// Smart URL handling - if it doesn't end with .json, append /index.json
62
62
-
if !strings.HasSuffix(url, ".json") {
63
63
-
url = strings.TrimSuffix(url, "/") + "/index.json"
64
64
-
}
65
65
-
66
66
-
client := &http.Client{
67
67
-
Timeout: 30 * time.Second,
68
68
-
}
69
69
-
70
70
-
resp, err := client.Get(url)
71
71
-
if err != nil {
72
72
-
return nil, fmt.Errorf("failed to download: %w", err)
73
73
-
}
74
74
-
defer resp.Body.Close()
75
75
-
76
76
-
if resp.StatusCode != http.StatusOK {
77
77
-
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
78
78
-
}
79
79
-
80
80
-
data, err := io.ReadAll(resp.Body)
81
81
-
if err != nil {
82
82
-
return nil, fmt.Errorf("failed to read response: %w", err)
83
83
-
}
84
84
-
85
85
-
var idx bundleindex.Index
86
86
-
if err := json.Unmarshal(data, &idx); err != nil {
87
87
-
return nil, fmt.Errorf("failed to parse index: %w", err)
88
88
-
}
89
89
-
90
90
-
return &idx, nil
91
91
-
}
92
92
-
93
93
-
// compareIndexes compares two indexes
94
94
-
func compareIndexes(local, target *bundleindex.Index) *IndexComparison {
95
95
-
localBundles := local.GetBundles()
96
96
-
targetBundles := target.GetBundles()
97
97
-
98
98
-
// Create maps for quick lookup
99
99
-
localMap := make(map[int]*bundleindex.BundleMetadata)
100
100
-
targetMap := make(map[int]*bundleindex.BundleMetadata)
101
101
-
102
102
-
for _, b := range localBundles {
103
103
-
localMap[b.BundleNumber] = b
104
104
-
}
105
105
-
for _, b := range targetBundles {
106
106
-
targetMap[b.BundleNumber] = b
107
107
-
}
108
108
-
109
109
-
comparison := &IndexComparison{
110
110
-
LocalCount: len(localBundles),
111
111
-
TargetCount: len(targetBundles),
112
112
-
MissingBundles: make([]int, 0),
113
113
-
ExtraBundles: make([]int, 0),
114
114
-
HashMismatches: make([]HashMismatch, 0),
115
115
-
ContentMismatches: make([]HashMismatch, 0),
116
116
-
}
117
117
-
118
118
-
// Get ranges
119
119
-
if len(localBundles) > 0 {
120
120
-
comparison.LocalRange = [2]int{localBundles[0].BundleNumber, localBundles[len(localBundles)-1].BundleNumber}
121
121
-
comparison.LocalUpdated = local.UpdatedAt
122
122
-
localStats := local.GetStats()
123
123
-
comparison.LocalTotalSize = localStats["total_size"].(int64)
124
124
-
}
125
125
-
126
126
-
if len(targetBundles) > 0 {
127
127
-
comparison.TargetRange = [2]int{targetBundles[0].BundleNumber, targetBundles[len(targetBundles)-1].BundleNumber}
128
128
-
comparison.TargetUpdated = target.UpdatedAt
129
129
-
targetStats := target.GetStats()
130
130
-
comparison.TargetTotalSize = targetStats["total_size"].(int64)
131
131
-
}
132
132
-
133
133
-
// Find missing bundles (in target but not in local)
134
134
-
for bundleNum := range targetMap {
135
135
-
if _, exists := localMap[bundleNum]; !exists {
136
136
-
comparison.MissingBundles = append(comparison.MissingBundles, bundleNum)
137
137
-
}
138
138
-
}
139
139
-
sort.Ints(comparison.MissingBundles)
140
140
-
141
141
-
// Find extra bundles (in local but not in target)
142
142
-
for bundleNum := range localMap {
143
143
-
if _, exists := targetMap[bundleNum]; !exists {
144
144
-
comparison.ExtraBundles = append(comparison.ExtraBundles, bundleNum)
145
145
-
}
146
146
-
}
147
147
-
sort.Ints(comparison.ExtraBundles)
148
148
-
149
149
-
// Compare hashes (Hash = chain hash, ContentHash = content hash)
150
150
-
for bundleNum, localMeta := range localMap {
151
151
-
if targetMeta, exists := targetMap[bundleNum]; exists {
152
152
-
comparison.CommonCount++
153
153
-
154
154
-
// Hash field is now the CHAIN HASH (most important!)
155
155
-
chainMismatch := localMeta.Hash != targetMeta.Hash
156
156
-
contentMismatch := localMeta.ContentHash != targetMeta.ContentHash
157
157
-
158
158
-
if chainMismatch || contentMismatch {
159
159
-
mismatch := HashMismatch{
160
160
-
BundleNumber: bundleNum,
161
161
-
LocalHash: localMeta.Hash, // Chain hash
162
162
-
TargetHash: targetMeta.Hash, // Chain hash
163
163
-
LocalContentHash: localMeta.ContentHash, // Content hash
164
164
-
TargetContentHash: targetMeta.ContentHash, // Content hash
165
165
-
}
166
166
-
167
167
-
// Separate chain hash mismatches (critical) from content mismatches
168
168
-
if chainMismatch {
169
169
-
comparison.HashMismatches = append(comparison.HashMismatches, mismatch)
170
170
-
}
171
171
-
if contentMismatch && !chainMismatch {
172
172
-
// Content mismatch but chain hash matches (unlikely but possible)
173
173
-
comparison.ContentMismatches = append(comparison.ContentMismatches, mismatch)
174
174
-
}
175
175
-
}
176
176
-
}
177
177
-
}
178
178
-
179
179
-
// Sort mismatches by bundle number
180
180
-
sort.Slice(comparison.HashMismatches, func(i, j int) bool {
181
181
-
return comparison.HashMismatches[i].BundleNumber < comparison.HashMismatches[j].BundleNumber
182
182
-
})
183
183
-
sort.Slice(comparison.ContentMismatches, func(i, j int) bool {
184
184
-
return comparison.ContentMismatches[i].BundleNumber < comparison.ContentMismatches[j].BundleNumber
185
185
-
})
186
186
-
187
187
-
return comparison
188
188
-
}
189
189
-
190
190
-
// displayComparison displays the comparison results
191
191
-
func displayComparison(c *IndexComparison, verbose bool) {
192
192
-
fmt.Printf("Comparison Results\n")
193
193
-
fmt.Printf("══════════════════\n\n")
194
194
-
195
195
-
// Summary
196
196
-
fmt.Printf("Summary\n")
197
197
-
fmt.Printf("───────\n")
198
198
-
fmt.Printf(" Local bundles: %d\n", c.LocalCount)
199
199
-
fmt.Printf(" Target bundles: %d\n", c.TargetCount)
200
200
-
fmt.Printf(" Common bundles: %d\n", c.CommonCount)
201
201
-
fmt.Printf(" Missing bundles: %s\n", formatCount(len(c.MissingBundles)))
202
202
-
fmt.Printf(" Extra bundles: %s\n", formatCount(len(c.ExtraBundles)))
203
203
-
fmt.Printf(" Hash mismatches: %s\n", formatCountCritical(len(c.HashMismatches)))
204
204
-
fmt.Printf(" Content mismatches: %s\n", formatCount(len(c.ContentMismatches)))
205
205
-
206
206
-
if c.LocalCount > 0 {
207
207
-
fmt.Printf("\n Local range: %06d - %06d\n", c.LocalRange[0], c.LocalRange[1])
208
208
-
fmt.Printf(" Local size: %.2f MB\n", float64(c.LocalTotalSize)/(1024*1024))
209
209
-
fmt.Printf(" Local updated: %s\n", c.LocalUpdated.Format("2006-01-02 15:04:05"))
210
210
-
}
211
211
-
212
212
-
if c.TargetCount > 0 {
213
213
-
fmt.Printf("\n Target range: %06d - %06d\n", c.TargetRange[0], c.TargetRange[1])
214
214
-
fmt.Printf(" Target size: %.2f MB\n", float64(c.TargetTotalSize)/(1024*1024))
215
215
-
fmt.Printf(" Target updated: %s\n", c.TargetUpdated.Format("2006-01-02 15:04:05"))
216
216
-
}
217
217
-
218
218
-
// Hash mismatches (CHAIN HASH - MOST CRITICAL)
219
219
-
if len(c.HashMismatches) > 0 {
220
220
-
fmt.Printf("\n")
221
221
-
fmt.Printf("⚠️ CHAIN HASH MISMATCHES (CRITICAL)\n")
222
222
-
fmt.Printf("════════════════════════════════════\n")
223
223
-
fmt.Printf("Chain hashes validate the entire bundle history.\n")
224
224
-
fmt.Printf("Mismatches indicate different bundle content or chain breaks.\n")
225
225
-
fmt.Printf("\n")
226
226
-
227
227
-
displayCount := len(c.HashMismatches)
228
228
-
if displayCount > 10 && !verbose {
229
229
-
displayCount = 10
230
230
-
}
231
231
-
232
232
-
for i := 0; i < displayCount; i++ {
233
233
-
m := c.HashMismatches[i]
234
234
-
fmt.Printf(" Bundle %06d:\n", m.BundleNumber)
235
235
-
236
236
-
// Show chain hashes (primary)
237
237
-
fmt.Printf(" Chain Hash:\n")
238
238
-
fmt.Printf(" Local: %s\n", m.LocalHash)
239
239
-
fmt.Printf(" Target: %s\n", m.TargetHash)
240
240
-
241
241
-
// Also show content hash if different
242
242
-
if m.LocalContentHash != m.TargetContentHash {
243
243
-
fmt.Printf(" Content Hash (also differs):\n")
244
244
-
fmt.Printf(" Local: %s\n", m.LocalContentHash)
245
245
-
fmt.Printf(" Target: %s\n", m.TargetContentHash)
246
246
-
}
247
247
-
fmt.Printf("\n")
248
248
-
}
249
249
-
250
250
-
if len(c.HashMismatches) > displayCount {
251
251
-
fmt.Printf(" ... and %d more (use -v to show all)\n\n", len(c.HashMismatches)-displayCount)
252
252
-
}
253
253
-
}
254
254
-
255
255
-
// Content hash mismatches (chain hash matches - unlikely but possible)
256
256
-
if len(c.ContentMismatches) > 0 {
257
257
-
fmt.Printf("\n")
258
258
-
fmt.Printf("Content Hash Mismatches (chain hash matches)\n")
259
259
-
fmt.Printf("─────────────────────────────────────────────\n")
260
260
-
fmt.Printf("This is unusual - content differs but chain hash matches.\n")
261
261
-
fmt.Printf("\n")
262
262
-
263
263
-
displayCount := len(c.ContentMismatches)
264
264
-
if displayCount > 10 && !verbose {
265
265
-
displayCount = 10
266
266
-
}
267
267
-
268
268
-
for i := 0; i < displayCount; i++ {
269
269
-
m := c.ContentMismatches[i]
270
270
-
fmt.Printf(" Bundle %06d:\n", m.BundleNumber)
271
271
-
fmt.Printf(" Content Hash:\n")
272
272
-
fmt.Printf(" Local: %s\n", m.LocalContentHash)
273
273
-
fmt.Printf(" Target: %s\n", m.TargetContentHash)
274
274
-
fmt.Printf(" Chain Hash (matches):\n")
275
275
-
fmt.Printf(" Both: %s\n", m.LocalHash)
276
276
-
}
277
277
-
278
278
-
if len(c.ContentMismatches) > displayCount {
279
279
-
fmt.Printf(" ... and %d more (use -v to show all)\n", len(c.ContentMismatches)-displayCount)
280
280
-
}
281
281
-
}
282
282
-
283
283
-
// Missing bundles
284
284
-
if len(c.MissingBundles) > 0 {
285
285
-
fmt.Printf("\n")
286
286
-
fmt.Printf("Missing Bundles (in target but not local)\n")
287
287
-
fmt.Printf("──────────────────────────────────────────\n")
288
288
-
289
289
-
if verbose || len(c.MissingBundles) <= 20 {
290
290
-
displayCount := len(c.MissingBundles)
291
291
-
if displayCount > 20 && !verbose {
292
292
-
displayCount = 20
293
293
-
}
294
294
-
295
295
-
for i := 0; i < displayCount; i++ {
296
296
-
fmt.Printf(" %06d\n", c.MissingBundles[i])
297
297
-
}
298
298
-
299
299
-
if len(c.MissingBundles) > displayCount {
300
300
-
fmt.Printf(" ... and %d more (use -v to show all)\n", len(c.MissingBundles)-displayCount)
301
301
-
}
302
302
-
} else {
303
303
-
displayBundleRanges(c.MissingBundles)
304
304
-
}
305
305
-
}
306
306
-
307
307
-
// Extra bundles
308
308
-
if len(c.ExtraBundles) > 0 {
309
309
-
fmt.Printf("\n")
310
310
-
fmt.Printf("Extra Bundles (in local but not target)\n")
311
311
-
fmt.Printf("────────────────────────────────────────\n")
312
312
-
313
313
-
if verbose || len(c.ExtraBundles) <= 20 {
314
314
-
displayCount := len(c.ExtraBundles)
315
315
-
if displayCount > 20 && !verbose {
316
316
-
displayCount = 20
317
317
-
}
318
318
-
319
319
-
for i := 0; i < displayCount; i++ {
320
320
-
fmt.Printf(" %06d\n", c.ExtraBundles[i])
321
321
-
}
322
322
-
323
323
-
if len(c.ExtraBundles) > displayCount {
324
324
-
fmt.Printf(" ... and %d more (use -v to show all)\n", len(c.ExtraBundles)-displayCount)
325
325
-
}
326
326
-
} else {
327
327
-
displayBundleRanges(c.ExtraBundles)
328
328
-
}
329
329
-
}
330
330
-
331
331
-
// Final status
332
332
-
fmt.Printf("\n")
333
333
-
if !c.HasDifferences() {
334
334
-
fmt.Printf("✓ Indexes are identical\n")
335
335
-
} else {
336
336
-
fmt.Printf("✗ Indexes have differences\n")
337
337
-
if len(c.HashMismatches) > 0 {
338
338
-
fmt.Printf("\n⚠️ WARNING: Chain hash mismatches detected!\n")
339
339
-
fmt.Printf("This indicates different bundle content or chain integrity issues.\n")
340
340
-
}
341
341
-
}
342
342
-
}
343
343
-
344
344
-
// formatCount formats a count with color/symbol
345
345
-
func formatCount(count int) string {
346
346
-
if count == 0 {
347
347
-
return "\033[32m0 ✓\033[0m" // Green with checkmark
348
348
-
}
349
349
-
return fmt.Sprintf("\033[33m%d ⚠️\033[0m", count) // Yellow with warning
350
350
-
}
351
351
-
352
352
-
// formatCountCritical formats a count for critical items (chain mismatches)
353
353
-
func formatCountCritical(count int) string {
354
354
-
if count == 0 {
355
355
-
return "\033[32m0 ✓\033[0m" // Green with checkmark
356
356
-
}
357
357
-
return fmt.Sprintf("\033[31m%d ✗\033[0m", count) // Red with X
358
358
-
}
359
359
-
360
360
-
// displayBundleRanges displays bundle numbers as ranges
361
361
-
func displayBundleRanges(bundles []int) {
362
362
-
if len(bundles) == 0 {
363
363
-
return
364
364
-
}
365
365
-
366
366
-
rangeStart := bundles[0]
367
367
-
rangeEnd := bundles[0]
368
368
-
369
369
-
for i := 1; i < len(bundles); i++ {
370
370
-
if bundles[i] == rangeEnd+1 {
371
371
-
rangeEnd = bundles[i]
372
372
-
} else {
373
373
-
// Print current range
374
374
-
if rangeStart == rangeEnd {
375
375
-
fmt.Printf(" %06d\n", rangeStart)
376
376
-
} else {
377
377
-
fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd)
378
378
-
}
379
379
-
rangeStart = bundles[i]
380
380
-
rangeEnd = bundles[i]
381
381
-
}
382
382
-
}
383
383
-
384
384
-
// Print last range
385
385
-
if rangeStart == rangeEnd {
386
386
-
fmt.Printf(" %06d\n", rangeStart)
387
387
-
} else {
388
388
-
fmt.Printf(" %06d - %06d\n", rangeStart, rangeEnd)
389
389
-
}
390
390
-
}
391
391
-
392
392
-
// fetchMissingBundles downloads missing bundles from target server
393
393
-
func fetchMissingBundles(mgr *bundle.Manager, baseURL string, missingBundles []int) {
394
394
-
client := &http.Client{
395
395
-
Timeout: 60 * time.Second,
396
396
-
}
397
397
-
398
398
-
successCount := 0
399
399
-
errorCount := 0
400
400
-
401
401
-
for _, bundleNum := range missingBundles {
402
402
-
fmt.Printf("Fetching bundle %06d... ", bundleNum)
403
403
-
404
404
-
// Download bundle data
405
405
-
url := fmt.Sprintf("%s/data/%d", baseURL, bundleNum)
406
406
-
resp, err := client.Get(url)
407
407
-
if err != nil {
408
408
-
fmt.Printf("ERROR: %v\n", err)
409
409
-
errorCount++
410
410
-
continue
411
411
-
}
412
412
-
413
413
-
if resp.StatusCode != http.StatusOK {
414
414
-
fmt.Printf("ERROR: status %d\n", resp.StatusCode)
415
415
-
resp.Body.Close()
416
416
-
errorCount++
417
417
-
continue
418
418
-
}
419
419
-
420
420
-
// Save to file
421
421
-
filename := fmt.Sprintf("%06d.jsonl.zst", bundleNum)
422
422
-
filepath := filepath.Join(mgr.GetInfo()["bundle_dir"].(string), filename)
423
423
-
424
424
-
outFile, err := os.Create(filepath)
425
425
-
if err != nil {
426
426
-
fmt.Printf("ERROR: %v\n", err)
427
427
-
resp.Body.Close()
428
428
-
errorCount++
429
429
-
continue
430
430
-
}
431
431
-
432
432
-
_, err = io.Copy(outFile, resp.Body)
433
433
-
outFile.Close()
434
434
-
resp.Body.Close()
435
435
-
436
436
-
if err != nil {
437
437
-
fmt.Printf("ERROR: %v\n", err)
438
438
-
os.Remove(filepath)
439
439
-
errorCount++
440
440
-
continue
441
441
-
}
442
442
-
443
443
-
// Scan and index the bundle
444
444
-
_, err = mgr.ScanAndIndexBundle(filepath, bundleNum)
445
445
-
if err != nil {
446
446
-
fmt.Printf("ERROR: %v\n", err)
447
447
-
errorCount++
448
448
-
continue
449
449
-
}
450
450
-
451
451
-
fmt.Printf("✓\n")
452
452
-
successCount++
453
453
-
454
454
-
// Small delay to be nice
455
455
-
time.Sleep(200 * time.Millisecond)
456
456
-
}
457
457
-
458
458
-
fmt.Printf("\n")
459
459
-
fmt.Printf("✓ Fetch complete: %d succeeded, %d failed\n", successCount, errorCount)
460
460
-
}
+150
-258
cmd/plcbundle/detector.go
cmd/plcbundle/commands/detector.go
···
1
1
-
// cmd/plcbundle/detector.go
2
2
-
package main
1
1
+
package commands
3
2
4
3
import (
5
4
"bufio"
···
11
10
"os"
12
11
"sort"
13
12
"strings"
14
14
-
"time"
15
13
16
14
"github.com/goccy/go-json"
17
17
-
18
15
"tangled.org/atscan.net/plcbundle/detector"
19
16
"tangled.org/atscan.net/plcbundle/plcclient"
20
17
)
21
18
22
22
-
type defaultLogger struct{}
23
23
-
24
24
-
func (d *defaultLogger) Printf(format string, v ...interface{}) {
25
25
-
fmt.Fprintf(os.Stderr, format+"\n", v...)
26
26
-
}
27
27
-
28
28
-
func cmdDetector() {
29
29
-
if len(os.Args) < 3 {
19
19
+
// DetectorCommand handles the detector subcommand
20
20
+
func DetectorCommand(args []string) error {
21
21
+
if len(args) < 1 {
30
22
printDetectorUsage()
31
31
-
os.Exit(1)
23
23
+
return fmt.Errorf("subcommand required")
32
24
}
33
25
34
34
-
subcommand := os.Args[2]
26
26
+
subcommand := args[0]
35
27
36
28
switch subcommand {
37
29
case "list":
38
38
-
cmdDetectorList()
30
30
+
return detectorList(args[1:])
39
31
case "test":
40
40
-
cmdDetectorTest()
32
32
+
return detectorTest(args[1:])
41
33
case "run":
42
42
-
cmdDetectorRun()
34
34
+
return detectorRun(args[1:])
43
35
case "filter":
44
44
-
cmdDetectorFilter()
36
36
+
return detectorFilter(args[1:])
45
37
case "info":
46
46
-
cmdDetectorInfo()
38
38
+
return detectorInfo(args[1:])
47
39
default:
48
48
-
fmt.Fprintf(os.Stderr, "Unknown detector subcommand: %s\n", subcommand)
49
40
printDetectorUsage()
50
50
-
os.Exit(1)
41
41
+
return fmt.Errorf("unknown detector subcommand: %s", subcommand)
51
42
}
52
43
}
53
44
···
62
53
info Show detailed detector information
63
54
64
55
Examples:
65
65
-
# List all built-in detectors
66
56
plcbundle detector list
67
67
-
68
68
-
# Run built-in detector
69
57
plcbundle detector run invalid_handle --bundles 1-100
70
70
-
71
71
-
# Run custom JavaScript detector
72
58
plcbundle detector run ./my_detector.js --bundles 1-100
73
73
-
74
74
-
# Run multiple detectors (built-in + custom)
75
75
-
plcbundle detector run invalid_handle ./my_detector.js --bundles 1-100
76
76
-
77
77
-
# Run all built-in detectors
78
59
plcbundle detector run all --bundles 1-100
79
79
-
80
80
-
# Filter with custom detector
81
60
plcbundle backfill | plcbundle detector filter ./my_detector.js > clean.jsonl
82
61
`)
83
62
}
84
63
85
85
-
// cmdDetectorFilter reads JSONL from stdin, filters OUT spam, outputs clean operations
86
86
-
func cmdDetectorFilter() {
87
87
-
if len(os.Args) < 4 {
88
88
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle detector filter <detector1|script.js> [detector2...] [--confidence 0.9]\n")
89
89
-
os.Exit(1)
90
90
-
}
91
91
-
92
92
-
// Parse detector names and flags
93
93
-
var detectorNames []string
94
94
-
var flagArgs []string
95
95
-
for i := 3; i < len(os.Args); i++ {
96
96
-
if strings.HasPrefix(os.Args[i], "-") {
97
97
-
flagArgs = os.Args[i:]
98
98
-
break
99
99
-
}
100
100
-
detectorNames = append(detectorNames, os.Args[i])
101
101
-
}
102
102
-
103
103
-
if len(detectorNames) == 0 {
104
104
-
fmt.Fprintf(os.Stderr, "Error: at least one detector name required\n")
105
105
-
os.Exit(1)
106
106
-
}
107
107
-
108
108
-
fs := flag.NewFlagSet("detector filter", flag.ExitOnError)
109
109
-
confidence := fs.Float64("confidence", 0.90, "minimum confidence")
110
110
-
fs.Parse(flagArgs)
111
111
-
112
112
-
// Load detectors (common logic)
113
113
-
setup, err := parseAndLoadDetectors(detectorNames, *confidence)
114
114
-
if err != nil {
115
115
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
116
116
-
os.Exit(1)
117
117
-
}
118
118
-
defer setup.cleanup()
119
119
-
120
120
-
fmt.Fprintf(os.Stderr, "Filtering with %d detector(s), min confidence: %.2f\n\n", len(setup.detectors), *confidence)
121
121
-
122
122
-
ctx := context.Background()
123
123
-
scanner := bufio.NewScanner(os.Stdin)
124
124
-
buf := make([]byte, 0, 64*1024)
125
125
-
scanner.Buffer(buf, 1024*1024)
126
126
-
127
127
-
cleanCount, filteredCount, totalCount := 0, 0, 0
128
128
-
totalBytes, filteredBytes := int64(0), int64(0)
129
129
-
130
130
-
for scanner.Scan() {
131
131
-
line := scanner.Bytes()
132
132
-
if len(line) == 0 {
133
133
-
continue
134
134
-
}
135
135
-
136
136
-
totalCount++
137
137
-
totalBytes += int64(len(line))
138
138
-
139
139
-
var op plcclient.PLCOperation
140
140
-
if err := json.Unmarshal(line, &op); err != nil {
141
141
-
continue
142
142
-
}
143
143
-
144
144
-
// Run detection (common logic)
145
145
-
labels, _ := detectOperation(ctx, setup.detectors, op, setup.confidence)
146
146
-
147
147
-
if len(labels) == 0 {
148
148
-
cleanCount++
149
149
-
fmt.Println(string(line))
150
150
-
} else {
151
151
-
filteredCount++
152
152
-
filteredBytes += int64(len(line))
153
153
-
}
154
154
-
155
155
-
if totalCount%1000 == 0 {
156
156
-
fmt.Fprintf(os.Stderr, "Processed: %d | Clean: %d | Filtered: %d\r", totalCount, cleanCount, filteredCount)
157
157
-
}
158
158
-
}
159
159
-
160
160
-
// Stats
161
161
-
fmt.Fprintf(os.Stderr, "\n\n✓ Filter complete\n")
162
162
-
fmt.Fprintf(os.Stderr, " Total: %d | Clean: %d (%.2f%%) | Filtered: %d (%.2f%%)\n",
163
163
-
totalCount, cleanCount, float64(cleanCount)/float64(totalCount)*100,
164
164
-
filteredCount, float64(filteredCount)/float64(totalCount)*100)
165
165
-
fmt.Fprintf(os.Stderr, " Size saved: %s (%.2f%%)\n", formatBytes(filteredBytes), float64(filteredBytes)/float64(totalBytes)*100)
166
166
-
}
167
167
-
168
168
-
func cmdDetectorList() {
64
64
+
func detectorList(args []string) error {
169
65
registry := detector.DefaultRegistry()
170
66
detectors := registry.List()
171
67
172
172
-
// Sort by name
173
68
sort.Slice(detectors, func(i, j int) bool {
174
69
return detectors[i].Name() < detectors[j].Name()
175
70
})
···
179
74
fmt.Printf(" %-20s %s (v%s)\n", d.Name(), d.Description(), d.Version())
180
75
}
181
76
fmt.Printf("\nUse 'plcbundle detector info <name>' for details\n")
77
77
+
78
78
+
return nil
182
79
}
183
80
184
184
-
func cmdDetectorTest() {
185
185
-
// Extract detector name first
186
186
-
if len(os.Args) < 4 {
187
187
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle detector test <detector-name> --bundle N\n")
188
188
-
os.Exit(1)
81
81
+
func detectorTest(args []string) error {
82
82
+
if len(args) < 1 {
83
83
+
return fmt.Errorf("usage: plcbundle detector test <detector-name> --bundle N")
189
84
}
190
85
191
191
-
detectorName := os.Args[3]
86
86
+
detectorName := args[0]
192
87
193
193
-
// Parse flags from os.Args[4:]
194
88
fs := flag.NewFlagSet("detector test", flag.ExitOnError)
195
89
bundleNum := fs.Int("bundle", 0, "bundle number to test")
196
90
confidence := fs.Float64("confidence", 0.90, "minimum confidence threshold")
197
91
verbose := fs.Bool("v", false, "verbose output")
198
198
-
fs.Parse(os.Args[4:])
92
92
+
93
93
+
if err := fs.Parse(args[1:]); err != nil {
94
94
+
return err
95
95
+
}
199
96
200
97
if *bundleNum == 0 {
201
201
-
fmt.Fprintf(os.Stderr, "Error: --bundle required\n")
202
202
-
os.Exit(1)
98
98
+
return fmt.Errorf("--bundle required")
203
99
}
204
100
205
205
-
// Load bundle
206
101
mgr, _, err := getManager("")
207
102
if err != nil {
208
208
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
209
209
-
os.Exit(1)
103
103
+
return err
210
104
}
211
105
defer mgr.Close()
212
106
213
107
ctx := context.Background()
214
108
bundle, err := mgr.LoadBundle(ctx, *bundleNum)
215
109
if err != nil {
216
216
-
fmt.Fprintf(os.Stderr, "Error loading bundle: %v\n", err)
217
217
-
os.Exit(1)
110
110
+
return fmt.Errorf("error loading bundle: %w", err)
218
111
}
219
112
220
113
fmt.Printf("Testing detector '%s' on bundle %06d...\n", detectorName, *bundleNum)
221
114
fmt.Printf("Min confidence: %.2f\n\n", *confidence)
222
115
223
223
-
// Run detector
224
116
registry := detector.DefaultRegistry()
225
117
config := detector.DefaultConfig()
226
118
config.MinConfidence = *confidence
···
228
120
runner := detector.NewRunner(registry, config, &defaultLogger{})
229
121
results, err := runner.RunOnBundle(ctx, detectorName, bundle)
230
122
if err != nil {
231
231
-
fmt.Fprintf(os.Stderr, "Detection failed: %v\n", err)
232
232
-
os.Exit(1)
123
123
+
return fmt.Errorf("detection failed: %w", err)
233
124
}
234
125
235
235
-
// Calculate stats
236
126
stats := detector.CalculateStats(results, len(bundle.Operations))
237
127
238
238
-
// Display results
239
128
fmt.Printf("Results:\n")
240
129
fmt.Printf(" Total operations: %d\n", stats.TotalOperations)
241
241
-
fmt.Printf(" Matches found: %d (%.2f%%)\n", stats.MatchedCount, stats.MatchRate*100)
242
242
-
fmt.Printf("\n")
130
130
+
fmt.Printf(" Matches found: %d (%.2f%%)\n\n", stats.MatchedCount, stats.MatchRate*100)
243
131
244
132
if len(stats.ByReason) > 0 {
245
133
fmt.Printf("Breakdown by reason:\n")
···
250
138
fmt.Printf("\n")
251
139
}
252
140
253
253
-
if len(stats.ByCategory) > 0 {
254
254
-
fmt.Printf("Breakdown by category:\n")
255
255
-
for category, count := range stats.ByCategory {
256
256
-
pct := float64(count) / float64(stats.MatchedCount) * 100
257
257
-
fmt.Printf(" %-25s %d (%.1f%%)\n", category, count, pct)
258
258
-
}
259
259
-
fmt.Printf("\n")
260
260
-
}
261
261
-
262
262
-
if len(stats.ByConfidence) > 0 {
263
263
-
fmt.Printf("Confidence distribution:\n")
264
264
-
for bucket, count := range stats.ByConfidence {
265
265
-
pct := float64(count) / float64(stats.MatchedCount) * 100
266
266
-
fmt.Printf(" %-25s %d (%.1f%%)\n", bucket, count, pct)
267
267
-
}
268
268
-
fmt.Printf("\n")
269
269
-
}
270
270
-
271
141
if *verbose && len(results) > 0 {
272
142
fmt.Printf("Sample matches (first 10):\n")
273
143
displayCount := 10
···
283
153
fmt.Printf(" Note: %s\n", res.Match.Note)
284
154
}
285
155
}
286
286
-
287
287
-
if len(results) > displayCount {
288
288
-
fmt.Printf(" ... and %d more\n", len(results)-displayCount)
289
289
-
}
290
156
}
157
157
+
158
158
+
return nil
291
159
}
292
160
293
293
-
func cmdDetectorRun() {
294
294
-
if len(os.Args) < 4 {
295
295
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle detector run <detector1|script.js> [detector2...] [--bundles 1-100]\n")
296
296
-
os.Exit(1)
161
161
+
func detectorRun(args []string) error {
162
162
+
if len(args) < 1 {
163
163
+
return fmt.Errorf("usage: plcbundle detector run <detector1|script.js> [detector2...] [--bundles 1-100]")
297
164
}
298
165
299
299
-
// Parse detector names and flags
300
166
var detectorNames []string
301
167
var flagArgs []string
302
302
-
for i := 3; i < len(os.Args); i++ {
303
303
-
if strings.HasPrefix(os.Args[i], "-") {
304
304
-
flagArgs = os.Args[i:]
168
168
+
for i := 0; i < len(args); i++ {
169
169
+
if strings.HasPrefix(args[i], "-") {
170
170
+
flagArgs = args[i:]
305
171
break
306
172
}
307
307
-
detectorNames = append(detectorNames, os.Args[i])
173
173
+
detectorNames = append(detectorNames, args[i])
308
174
}
309
175
310
176
if len(detectorNames) == 0 {
311
311
-
fmt.Fprintf(os.Stderr, "Error: at least one detector name required\n")
312
312
-
os.Exit(1)
177
177
+
return fmt.Errorf("at least one detector name required")
313
178
}
314
179
315
180
fs := flag.NewFlagSet("detector run", flag.ExitOnError)
316
316
-
bundleRange := fs.String("bundles", "", "bundle range, default: all bundles")
181
181
+
bundleRange := fs.String("bundles", "", "bundle range (default: all)")
317
182
confidence := fs.Float64("confidence", 0.90, "minimum confidence")
318
183
pprofPort := fs.String("pprof", "", "enable pprof on port (e.g., :6060)")
319
319
-
fs.Parse(flagArgs)
320
184
321
321
-
// Start pprof server if requested
185
185
+
if err := fs.Parse(flagArgs); err != nil {
186
186
+
return err
187
187
+
}
188
188
+
189
189
+
// Start pprof if requested
322
190
if *pprofPort != "" {
323
191
go func() {
324
192
fmt.Fprintf(os.Stderr, "pprof server starting on http://localhost%s/debug/pprof/\n", *pprofPort)
325
325
-
if err := http.ListenAndServe(*pprofPort, nil); err != nil {
326
326
-
fmt.Fprintf(os.Stderr, "pprof server failed: %v\n", err)
327
327
-
}
193
193
+
http.ListenAndServe(*pprofPort, nil)
328
194
}()
329
329
-
time.Sleep(100 * time.Millisecond) // Let server start
330
195
}
331
196
332
332
-
// Load manager
333
197
mgr, _, err := getManager("")
334
198
if err != nil {
335
335
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
336
336
-
os.Exit(1)
199
199
+
return err
337
200
}
338
201
defer mgr.Close()
339
202
340
340
-
// Determine bundle range
203
203
+
// Determine range
341
204
var start, end int
342
205
if *bundleRange == "" {
343
206
index := mgr.GetIndex()
344
207
bundles := index.GetBundles()
345
208
if len(bundles) == 0 {
346
346
-
fmt.Fprintf(os.Stderr, "Error: no bundles available\n")
347
347
-
os.Exit(1)
209
209
+
return fmt.Errorf("no bundles available")
348
210
}
349
211
start = bundles[0].BundleNumber
350
212
end = bundles[len(bundles)-1].BundleNumber
···
352
214
} else {
353
215
start, end, err = parseBundleRange(*bundleRange)
354
216
if err != nil {
355
355
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
356
356
-
os.Exit(1)
217
217
+
return err
357
218
}
358
219
}
359
220
360
360
-
// Load detectors (common logic)
221
221
+
// Load detectors
361
222
setup, err := parseAndLoadDetectors(detectorNames, *confidence)
362
223
if err != nil {
363
363
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
364
364
-
os.Exit(1)
224
224
+
return err
365
225
}
366
226
defer setup.cleanup()
367
227
···
371
231
ctx := context.Background()
372
232
fmt.Println("bundle,position,cid,size,confidence,labels")
373
233
374
374
-
// Stats
375
234
totalOps, matchCount := 0, 0
376
235
totalBytes, matchedBytes := int64(0), int64(0)
377
377
-
totalBundles := end - start + 1
378
378
-
progress := NewProgressBar(totalBundles)
379
379
-
progress.showBytes = true
380
236
381
381
-
// Process bundles
382
237
for bundleNum := start; bundleNum <= end; bundleNum++ {
383
238
bundle, err := mgr.LoadBundle(ctx, bundleNum)
384
239
if err != nil {
···
395
250
}
396
251
totalBytes += int64(opSize)
397
252
398
398
-
// Run detection (common logic)
399
399
-
labels, confidence := detectOperation(ctx, setup.detectors, op, setup.confidence)
253
253
+
labels, conf := detectOperation(ctx, setup.detectors, op, setup.confidence)
400
254
401
255
if len(labels) > 0 {
402
256
matchCount++
···
408
262
}
409
263
410
264
fmt.Printf("%d,%d,%s,%d,%.2f,%s\n",
411
411
-
bundleNum, position, cidShort, opSize, confidence, strings.Join(labels, ";"))
265
265
+
bundleNum, position, cidShort, opSize, conf, strings.Join(labels, ";"))
412
266
}
413
267
}
414
414
-
415
415
-
progress.SetWithBytes(bundleNum-start+1, totalBytes)
416
268
}
417
269
418
418
-
progress.Finish()
419
419
-
420
420
-
// Stats
421
270
fmt.Fprintf(os.Stderr, "\n✓ Detection complete\n")
422
271
fmt.Fprintf(os.Stderr, " Total operations: %d\n", totalOps)
423
272
fmt.Fprintf(os.Stderr, " Matches found: %d (%.2f%%)\n", matchCount, float64(matchCount)/float64(totalOps)*100)
424
424
-
fmt.Fprintf(os.Stderr, " Total size: %s\n", formatBytes(totalBytes))
425
425
-
fmt.Fprintf(os.Stderr, " Matched size: %s (%.2f%%)\n", formatBytes(matchedBytes), float64(matchedBytes)/float64(totalBytes)*100)
273
273
+
274
274
+
return nil
426
275
}
427
276
428
428
-
func cmdDetectorInfo() {
429
429
-
if len(os.Args) < 4 {
430
430
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle detector info <name>\n")
431
431
-
os.Exit(1)
277
277
+
func detectorFilter(args []string) error {
278
278
+
if len(args) < 1 {
279
279
+
return fmt.Errorf("usage: plcbundle detector filter <detector1|script.js> [detector2...] [--confidence 0.9]")
280
280
+
}
281
281
+
282
282
+
var detectorNames []string
283
283
+
var flagArgs []string
284
284
+
for i := 0; i < len(args); i++ {
285
285
+
if strings.HasPrefix(args[i], "-") {
286
286
+
flagArgs = args[i:]
287
287
+
break
288
288
+
}
289
289
+
detectorNames = append(detectorNames, args[i])
290
290
+
}
291
291
+
292
292
+
if len(detectorNames) == 0 {
293
293
+
return fmt.Errorf("at least one detector name required")
432
294
}
433
295
434
434
-
detectorName := os.Args[3]
296
296
+
fs := flag.NewFlagSet("detector filter", flag.ExitOnError)
297
297
+
confidence := fs.Float64("confidence", 0.90, "minimum confidence")
298
298
+
299
299
+
if err := fs.Parse(flagArgs); err != nil {
300
300
+
return err
301
301
+
}
435
302
436
436
-
registry := detector.DefaultRegistry()
437
437
-
d, err := registry.Get(detectorName)
303
303
+
setup, err := parseAndLoadDetectors(detectorNames, *confidence)
438
304
if err != nil {
439
439
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
440
440
-
os.Exit(1)
305
305
+
return err
441
306
}
307
307
+
defer setup.cleanup()
442
308
443
443
-
fmt.Printf("Detector: %s\n", d.Name())
444
444
-
fmt.Printf("Version: %s\n", d.Version())
445
445
-
fmt.Printf("Description: %s\n", d.Description())
446
446
-
fmt.Printf("\n")
309
309
+
fmt.Fprintf(os.Stderr, "Filtering with %d detector(s), min confidence: %.2f\n\n", len(setup.detectors), *confidence)
447
310
448
448
-
// Show example usage
449
449
-
fmt.Printf("Usage examples:\n")
450
450
-
fmt.Printf(" # Test on single bundle\n")
451
451
-
fmt.Printf(" plcbundle detector test %s --bundle 42\n\n", d.Name())
452
452
-
fmt.Printf(" # Run on range and save\n")
453
453
-
fmt.Printf(" plcbundle detector run %s --bundles 1-100 --output results.csv\n\n", d.Name())
454
454
-
fmt.Printf(" # Use with filter creation\n")
455
455
-
fmt.Printf(" plcbundle filter detect --detector %s --bundles 1-100\n", d.Name())
456
456
-
}
311
311
+
ctx := context.Background()
312
312
+
scanner := bufio.NewScanner(os.Stdin)
313
313
+
buf := make([]byte, 0, 64*1024)
314
314
+
scanner.Buffer(buf, 1024*1024)
315
315
+
316
316
+
cleanCount, filteredCount, totalCount := 0, 0, 0
317
317
+
totalBytes, filteredBytes := int64(0), int64(0)
457
318
458
458
-
// Helper functions
319
319
+
for scanner.Scan() {
320
320
+
line := scanner.Bytes()
321
321
+
if len(line) == 0 {
322
322
+
continue
323
323
+
}
324
324
+
325
325
+
totalCount++
326
326
+
totalBytes += int64(len(line))
327
327
+
328
328
+
var op plcclient.PLCOperation
329
329
+
if err := json.Unmarshal(line, &op); err != nil {
330
330
+
continue
331
331
+
}
332
332
+
333
333
+
labels, _ := detectOperation(ctx, setup.detectors, op, setup.confidence)
459
334
460
460
-
func parseBundleRange(rangeStr string) (start, end int, err error) {
461
461
-
// Handle single bundle number
462
462
-
if !strings.Contains(rangeStr, "-") {
463
463
-
var num int
464
464
-
_, err = fmt.Sscanf(rangeStr, "%d", &num)
465
465
-
if err != nil {
466
466
-
return 0, 0, fmt.Errorf("invalid bundle number: %w", err)
335
335
+
if len(labels) == 0 {
336
336
+
cleanCount++
337
337
+
fmt.Println(string(line))
338
338
+
} else {
339
339
+
filteredCount++
340
340
+
filteredBytes += int64(len(line))
467
341
}
468
468
-
return num, num, nil
469
469
-
}
470
342
471
471
-
// Handle range (e.g., "1-100")
472
472
-
parts := strings.Split(rangeStr, "-")
473
473
-
if len(parts) != 2 {
474
474
-
return 0, 0, fmt.Errorf("invalid range format (expected: N or start-end)")
343
343
+
if totalCount%1000 == 0 {
344
344
+
fmt.Fprintf(os.Stderr, "Processed: %d | Clean: %d | Filtered: %d\r", totalCount, cleanCount, filteredCount)
345
345
+
}
475
346
}
476
347
477
477
-
_, err = fmt.Sscanf(parts[0], "%d", &start)
478
478
-
if err != nil {
479
479
-
return 0, 0, fmt.Errorf("invalid start: %w", err)
348
348
+
fmt.Fprintf(os.Stderr, "\n\n✓ Filter complete\n")
349
349
+
fmt.Fprintf(os.Stderr, " Total: %d | Clean: %d (%.2f%%) | Filtered: %d (%.2f%%)\n",
350
350
+
totalCount, cleanCount, float64(cleanCount)/float64(totalCount)*100,
351
351
+
filteredCount, float64(filteredCount)/float64(totalCount)*100)
352
352
+
fmt.Fprintf(os.Stderr, " Size saved: %s (%.2f%%)\n", formatBytes(filteredBytes), float64(filteredBytes)/float64(totalBytes)*100)
353
353
+
354
354
+
return nil
355
355
+
}
356
356
+
357
357
+
func detectorInfo(args []string) error {
358
358
+
if len(args) < 1 {
359
359
+
return fmt.Errorf("usage: plcbundle detector info <name>")
480
360
}
481
361
482
482
-
_, err = fmt.Sscanf(parts[1], "%d", &end)
362
362
+
detectorName := args[0]
363
363
+
364
364
+
registry := detector.DefaultRegistry()
365
365
+
d, err := registry.Get(detectorName)
483
366
if err != nil {
484
484
-
return 0, 0, fmt.Errorf("invalid end: %w", err)
367
367
+
return err
485
368
}
486
369
487
487
-
if start > end {
488
488
-
return 0, 0, fmt.Errorf("start must be <= end")
489
489
-
}
370
370
+
fmt.Printf("Detector: %s\n", d.Name())
371
371
+
fmt.Printf("Version: %s\n", d.Version())
372
372
+
fmt.Printf("Description: %s\n\n", d.Description())
373
373
+
374
374
+
fmt.Printf("Usage examples:\n")
375
375
+
fmt.Printf(" # Test on single bundle\n")
376
376
+
fmt.Printf(" plcbundle detector test %s --bundle 42\n\n", d.Name())
377
377
+
fmt.Printf(" # Run on range and save\n")
378
378
+
fmt.Printf(" plcbundle detector run %s --bundles 1-100 > results.csv\n\n", d.Name())
490
379
491
491
-
return start, end, nil
380
380
+
return nil
492
381
}
493
382
494
494
-
// Common detector setup
383
383
+
// Helper functions
384
384
+
495
385
type detectorSetup struct {
496
386
detectors []detector.Detector
497
387
scriptDetectors []interface{ Close() error }
···
504
394
}
505
395
}
506
396
507
507
-
// parseAndLoadDetectors handles common detector loading logic
508
397
func parseAndLoadDetectors(detectorNames []string, confidence float64) (*detectorSetup, error) {
509
398
registry := detector.DefaultRegistry()
510
399
···
521
410
522
411
for _, name := range detectorNames {
523
412
if strings.HasSuffix(name, ".js") {
524
524
-
sd, err := detector.NewScriptDetector(name) // Simple single process
413
413
+
sd, err := detector.NewScriptDetector(name)
525
414
if err != nil {
526
415
setup.cleanup()
527
416
return nil, fmt.Errorf("error loading script %s: %w", name, err)
···
543
432
return setup, nil
544
433
}
545
434
546
546
-
// detectOperation runs all detectors on an operation and returns labels + confidence
547
435
func detectOperation(ctx context.Context, detectors []detector.Detector, op plcclient.PLCOperation, minConfidence float64) ([]string, float64) {
548
548
-
// Parse Operation ONCE before running detectors
549
436
opData, err := op.GetOperationData()
550
437
if err != nil {
551
438
return nil, 0
552
439
}
553
553
-
op.ParsedOperation = opData // Set for detectors to use
440
440
+
op.ParsedOperation = opData
554
441
555
442
var matchedLabels []string
556
443
var maxConfidence float64
···
561
448
continue
562
449
}
563
450
564
564
-
// Extract labels
565
451
var labels []string
566
452
if labelList, ok := match.Metadata["labels"].([]string); ok {
567
453
labels = labelList
···
585
471
586
472
return matchedLabels, maxConfidence
587
473
}
474
474
+
475
475
+
type defaultLogger struct{}
476
476
+
477
477
+
func (d *defaultLogger) Printf(format string, v ...interface{}) {
478
478
+
fmt.Fprintf(os.Stderr, format+"\n", v...)
479
479
+
}
-609
cmd/plcbundle/did_index.go
···
1
1
-
package main
2
2
-
3
3
-
import (
4
4
-
"context"
5
5
-
"flag"
6
6
-
"fmt"
7
7
-
"os"
8
8
-
"strings"
9
9
-
"time"
10
10
-
11
11
-
"github.com/goccy/go-json"
12
12
-
"tangled.org/atscan.net/plcbundle/internal/didindex"
13
13
-
"tangled.org/atscan.net/plcbundle/plcclient"
14
14
-
)
15
15
-
16
16
-
func cmdDIDIndex() {
17
17
-
if len(os.Args) < 3 {
18
18
-
printDIDIndexUsage()
19
19
-
os.Exit(1)
20
20
-
}
21
21
-
22
22
-
subcommand := os.Args[2]
23
23
-
24
24
-
switch subcommand {
25
25
-
case "build":
26
26
-
cmdDIDIndexBuild()
27
27
-
case "stats":
28
28
-
cmdDIDIndexStats()
29
29
-
case "lookup":
30
30
-
cmdDIDIndexLookup()
31
31
-
case "resolve":
32
32
-
cmdDIDIndexResolve()
33
33
-
default:
34
34
-
fmt.Fprintf(os.Stderr, "Unknown index subcommand: %s\n", subcommand)
35
35
-
printDIDIndexUsage()
36
36
-
os.Exit(1)
37
37
-
}
38
38
-
}
39
39
-
40
40
-
func printDIDIndexUsage() {
41
41
-
fmt.Printf(`Usage: plcbundle index <command> [options]
42
42
-
43
43
-
Commands:
44
44
-
build Build DID index from bundles
45
45
-
stats Show index statistics
46
46
-
lookup Lookup a specific DID
47
47
-
resolve Resolve DID to current document
48
48
-
49
49
-
Examples:
50
50
-
plcbundle index build
51
51
-
plcbundle index stats
52
52
-
plcbundle index lookup -v did:plc:524tuhdhh3m7li5gycdn6boe
53
53
-
plcbundle index resolve did:plc:524tuhdhh3m7li5gycdn6boe
54
54
-
`)
55
55
-
}
56
56
-
57
57
-
func cmdDIDIndexBuild() {
58
58
-
fs := flag.NewFlagSet("index build", flag.ExitOnError)
59
59
-
force := fs.Bool("force", false, "rebuild even if index exists")
60
60
-
fs.Parse(os.Args[3:])
61
61
-
62
62
-
mgr, dir, err := getManager("")
63
63
-
if err != nil {
64
64
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
65
65
-
os.Exit(1)
66
66
-
}
67
67
-
defer mgr.Close()
68
68
-
69
69
-
// Check if index exists
70
70
-
stats := mgr.GetDIDIndexStats()
71
71
-
if stats["exists"].(bool) && !*force {
72
72
-
fmt.Printf("DID index already exists (use --force to rebuild)\n")
73
73
-
fmt.Printf("Directory: %s\n", dir)
74
74
-
fmt.Printf("Total DIDs: %d\n", stats["total_dids"])
75
75
-
return
76
76
-
}
77
77
-
78
78
-
fmt.Printf("Building DID index in: %s\n", dir)
79
79
-
80
80
-
index := mgr.GetIndex()
81
81
-
bundleCount := index.Count()
82
82
-
83
83
-
if bundleCount == 0 {
84
84
-
fmt.Printf("No bundles to index\n")
85
85
-
return
86
86
-
}
87
87
-
88
88
-
fmt.Printf("Indexing %d bundles...\n\n", bundleCount)
89
89
-
90
90
-
progress := NewProgressBar(bundleCount)
91
91
-
92
92
-
start := time.Now()
93
93
-
ctx := context.Background()
94
94
-
95
95
-
err = mgr.BuildDIDIndex(ctx, func(current, total int) {
96
96
-
progress.Set(current)
97
97
-
})
98
98
-
99
99
-
progress.Finish()
100
100
-
101
101
-
if err != nil {
102
102
-
fmt.Fprintf(os.Stderr, "\nError building index: %v\n", err)
103
103
-
os.Exit(1)
104
104
-
}
105
105
-
106
106
-
elapsed := time.Since(start)
107
107
-
108
108
-
stats = mgr.GetDIDIndexStats()
109
109
-
110
110
-
fmt.Printf("\n✓ DID index built in %s\n", elapsed.Round(time.Millisecond))
111
111
-
fmt.Printf(" Total DIDs: %s\n", formatNumber(int(stats["total_dids"].(int64))))
112
112
-
fmt.Printf(" Shards: %d\n", stats["shard_count"])
113
113
-
fmt.Printf(" Location: %s/.plcbundle/\n", dir)
114
114
-
}
115
115
-
116
116
-
func cmdDIDIndexStats() {
117
117
-
mgr, dir, err := getManager("")
118
118
-
if err != nil {
119
119
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
120
120
-
os.Exit(1)
121
121
-
}
122
122
-
defer mgr.Close()
123
123
-
124
124
-
stats := mgr.GetDIDIndexStats()
125
125
-
126
126
-
if !stats["exists"].(bool) {
127
127
-
fmt.Printf("DID index does not exist\n")
128
128
-
fmt.Printf("Run: plcbundle index build\n")
129
129
-
return
130
130
-
}
131
131
-
132
132
-
indexedDIDs := stats["indexed_dids"].(int64)
133
133
-
mempoolDIDs := stats["mempool_dids"].(int64)
134
134
-
totalDIDs := stats["total_dids"].(int64)
135
135
-
136
136
-
fmt.Printf("\nDID Index Statistics\n")
137
137
-
fmt.Printf("════════════════════\n\n")
138
138
-
fmt.Printf(" Location: %s/.plcbundle/\n", dir)
139
139
-
140
140
-
if mempoolDIDs > 0 {
141
141
-
fmt.Printf(" Indexed DIDs: %s (in bundles)\n", formatNumber(int(indexedDIDs)))
142
142
-
fmt.Printf(" Mempool DIDs: %s (not yet bundled)\n", formatNumber(int(mempoolDIDs)))
143
143
-
fmt.Printf(" Total DIDs: %s\n", formatNumber(int(totalDIDs)))
144
144
-
} else {
145
145
-
fmt.Printf(" Total DIDs: %s\n", formatNumber(int(totalDIDs)))
146
146
-
}
147
147
-
148
148
-
fmt.Printf(" Shard count: %d\n", stats["shard_count"])
149
149
-
fmt.Printf(" Last bundle: %06d\n", stats["last_bundle"])
150
150
-
fmt.Printf(" Updated: %s\n", stats["updated_at"].(time.Time).Format("2006-01-02 15:04:05"))
151
151
-
fmt.Printf("\n")
152
152
-
fmt.Printf(" Cached shards: %d / %d\n", stats["cached_shards"], stats["cache_limit"])
153
153
-
154
154
-
if cachedList, ok := stats["cache_order"].([]int); ok && len(cachedList) > 0 {
155
155
-
fmt.Printf(" Hot shards: ")
156
156
-
for i, shard := range cachedList {
157
157
-
if i > 0 {
158
158
-
fmt.Printf(", ")
159
159
-
}
160
160
-
if i >= 10 {
161
161
-
fmt.Printf("... (+%d more)", len(cachedList)-10)
162
162
-
break
163
163
-
}
164
164
-
fmt.Printf("%02x", shard)
165
165
-
}
166
166
-
fmt.Printf("\n")
167
167
-
}
168
168
-
169
169
-
fmt.Printf("\n")
170
170
-
}
171
171
-
172
172
-
func cmdDIDIndexLookup() {
173
173
-
if len(os.Args) < 4 {
174
174
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle index lookup <did> [-v]\n")
175
175
-
os.Exit(1)
176
176
-
}
177
177
-
178
178
-
fs := flag.NewFlagSet("index lookup", flag.ExitOnError)
179
179
-
verbose := fs.Bool("v", false, "verbose debug output")
180
180
-
showJSON := fs.Bool("json", false, "output as JSON")
181
181
-
fs.Parse(os.Args[3:])
182
182
-
183
183
-
if fs.NArg() < 1 {
184
184
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle index lookup <did> [-v] [--json]\n")
185
185
-
os.Exit(1)
186
186
-
}
187
187
-
188
188
-
did := fs.Arg(0)
189
189
-
190
190
-
mgr, _, err := getManager("")
191
191
-
if err != nil {
192
192
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
193
193
-
os.Exit(1)
194
194
-
}
195
195
-
defer mgr.Close()
196
196
-
197
197
-
stats := mgr.GetDIDIndexStats()
198
198
-
if !stats["exists"].(bool) {
199
199
-
fmt.Fprintf(os.Stderr, "⚠️ DID index does not exist. Run: plcbundle index build\n")
200
200
-
fmt.Fprintf(os.Stderr, " Falling back to full scan (this will be slow)...\n\n")
201
201
-
}
202
202
-
203
203
-
if !*showJSON {
204
204
-
fmt.Printf("Looking up: %s\n", did)
205
205
-
if *verbose {
206
206
-
fmt.Printf("Verbose mode: enabled\n")
207
207
-
}
208
208
-
fmt.Printf("\n")
209
209
-
}
210
210
-
211
211
-
// === TIMING START ===
212
212
-
totalStart := time.Now()
213
213
-
ctx := context.Background()
214
214
-
215
215
-
// === STEP 1: Index/Scan Lookup ===
216
216
-
lookupStart := time.Now()
217
217
-
opsWithLoc, err := mgr.GetDIDOperationsWithLocations(ctx, did, *verbose)
218
218
-
if err != nil {
219
219
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
220
220
-
os.Exit(1)
221
221
-
}
222
222
-
lookupElapsed := time.Since(lookupStart)
223
223
-
224
224
-
// === STEP 2: Mempool Lookup ===
225
225
-
mempoolStart := time.Now()
226
226
-
mempoolOps, err := mgr.GetDIDOperationsFromMempool(did)
227
227
-
if err != nil {
228
228
-
fmt.Fprintf(os.Stderr, "Error checking mempool: %v\n", err)
229
229
-
os.Exit(1)
230
230
-
}
231
231
-
mempoolElapsed := time.Since(mempoolStart)
232
232
-
233
233
-
totalElapsed := time.Since(totalStart)
234
234
-
235
235
-
// === NOT FOUND ===
236
236
-
if len(opsWithLoc) == 0 && len(mempoolOps) == 0 {
237
237
-
if *showJSON {
238
238
-
fmt.Println("{\"found\": false, \"operations\": []}")
239
239
-
} else {
240
240
-
fmt.Printf("DID not found (searched in %s)\n", totalElapsed)
241
241
-
}
242
242
-
return
243
243
-
}
244
244
-
245
245
-
// === JSON OUTPUT MODE ===
246
246
-
if *showJSON {
247
247
-
output := map[string]interface{}{
248
248
-
"found": true,
249
249
-
"did": did,
250
250
-
"timing": map[string]interface{}{
251
251
-
"total_ms": totalElapsed.Milliseconds(),
252
252
-
"lookup_ms": lookupElapsed.Milliseconds(),
253
253
-
"mempool_ms": mempoolElapsed.Milliseconds(),
254
254
-
},
255
255
-
"bundled": make([]map[string]interface{}, 0),
256
256
-
"mempool": make([]map[string]interface{}, 0),
257
257
-
}
258
258
-
259
259
-
for _, owl := range opsWithLoc {
260
260
-
output["bundled"] = append(output["bundled"].([]map[string]interface{}), map[string]interface{}{
261
261
-
"bundle": owl.Bundle,
262
262
-
"position": owl.Position,
263
263
-
"cid": owl.Operation.CID,
264
264
-
"nullified": owl.Operation.IsNullified(),
265
265
-
"created_at": owl.Operation.CreatedAt.Format(time.RFC3339Nano),
266
266
-
})
267
267
-
}
268
268
-
269
269
-
for _, op := range mempoolOps {
270
270
-
output["mempool"] = append(output["mempool"].([]map[string]interface{}), map[string]interface{}{
271
271
-
"cid": op.CID,
272
272
-
"nullified": op.IsNullified(),
273
273
-
"created_at": op.CreatedAt.Format(time.RFC3339Nano),
274
274
-
})
275
275
-
}
276
276
-
277
277
-
data, _ := json.MarshalIndent(output, "", " ")
278
278
-
fmt.Println(string(data))
279
279
-
return
280
280
-
}
281
281
-
282
282
-
// === CALCULATE STATISTICS ===
283
283
-
nullifiedCount := 0
284
284
-
for _, owl := range opsWithLoc {
285
285
-
if owl.Operation.IsNullified() {
286
286
-
nullifiedCount++
287
287
-
}
288
288
-
}
289
289
-
290
290
-
totalOps := len(opsWithLoc) + len(mempoolOps)
291
291
-
activeOps := len(opsWithLoc) - nullifiedCount + len(mempoolOps)
292
292
-
293
293
-
// === DISPLAY SUMMARY ===
294
294
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
295
295
-
fmt.Printf(" DID Lookup Results\n")
296
296
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n\n")
297
297
-
298
298
-
fmt.Printf("DID: %s\n\n", did)
299
299
-
300
300
-
fmt.Printf("Summary\n")
301
301
-
fmt.Printf("───────\n")
302
302
-
fmt.Printf(" Total operations: %d\n", totalOps)
303
303
-
fmt.Printf(" Active operations: %d\n", activeOps)
304
304
-
if nullifiedCount > 0 {
305
305
-
fmt.Printf(" Nullified: %d\n", nullifiedCount)
306
306
-
}
307
307
-
if len(opsWithLoc) > 0 {
308
308
-
fmt.Printf(" Bundled: %d\n", len(opsWithLoc))
309
309
-
}
310
310
-
if len(mempoolOps) > 0 {
311
311
-
fmt.Printf(" Mempool: %d\n", len(mempoolOps))
312
312
-
}
313
313
-
fmt.Printf("\n")
314
314
-
315
315
-
// === TIMING BREAKDOWN ===
316
316
-
fmt.Printf("Performance\n")
317
317
-
fmt.Printf("───────────\n")
318
318
-
fmt.Printf(" Index lookup: %s\n", lookupElapsed)
319
319
-
fmt.Printf(" Mempool check: %s\n", mempoolElapsed)
320
320
-
fmt.Printf(" Total time: %s\n", totalElapsed)
321
321
-
322
322
-
if len(opsWithLoc) > 0 {
323
323
-
avgPerOp := lookupElapsed / time.Duration(len(opsWithLoc))
324
324
-
fmt.Printf(" Avg per operation: %s\n", avgPerOp)
325
325
-
}
326
326
-
fmt.Printf("\n")
327
327
-
328
328
-
// === BUNDLED OPERATIONS ===
329
329
-
if len(opsWithLoc) > 0 {
330
330
-
fmt.Printf("Bundled Operations (%d total)\n", len(opsWithLoc))
331
331
-
fmt.Printf("══════════════════════════════════════════════════════════════\n\n")
332
332
-
333
333
-
for i, owl := range opsWithLoc {
334
334
-
op := owl.Operation
335
335
-
status := "✓ Active"
336
336
-
statusSymbol := "✓"
337
337
-
if op.IsNullified() {
338
338
-
status = "✗ Nullified"
339
339
-
statusSymbol = "✗"
340
340
-
}
341
341
-
342
342
-
fmt.Printf("%s Operation %d [Bundle %06d, Position %04d]\n",
343
343
-
statusSymbol, i+1, owl.Bundle, owl.Position)
344
344
-
fmt.Printf(" CID: %s\n", op.CID)
345
345
-
fmt.Printf(" Created: %s\n", op.CreatedAt.Format("2006-01-02 15:04:05.000 MST"))
346
346
-
fmt.Printf(" Status: %s\n", status)
347
347
-
348
348
-
if op.IsNullified() {
349
349
-
if nullCID := op.GetNullifyingCID(); nullCID != "" {
350
350
-
fmt.Printf(" Nullified: %s\n", nullCID)
351
351
-
}
352
352
-
}
353
353
-
354
354
-
// Show operation type if verbose
355
355
-
if *verbose {
356
356
-
if opData, err := op.GetOperationData(); err == nil && opData != nil {
357
357
-
if opType, ok := opData["type"].(string); ok {
358
358
-
fmt.Printf(" Type: %s\n", opType)
359
359
-
}
360
360
-
361
361
-
// Show handle if present
362
362
-
if handle, ok := opData["handle"].(string); ok {
363
363
-
fmt.Printf(" Handle: %s\n", handle)
364
364
-
} else if aka, ok := opData["alsoKnownAs"].([]interface{}); ok && len(aka) > 0 {
365
365
-
if akaStr, ok := aka[0].(string); ok {
366
366
-
handle := strings.TrimPrefix(akaStr, "at://")
367
367
-
fmt.Printf(" Handle: %s\n", handle)
368
368
-
}
369
369
-
}
370
370
-
371
371
-
// Show service if present
372
372
-
if services, ok := opData["services"].(map[string]interface{}); ok {
373
373
-
if pds, ok := services["atproto_pds"].(map[string]interface{}); ok {
374
374
-
if endpoint, ok := pds["endpoint"].(string); ok {
375
375
-
fmt.Printf(" PDS: %s\n", endpoint)
376
376
-
}
377
377
-
}
378
378
-
}
379
379
-
}
380
380
-
}
381
381
-
382
382
-
fmt.Printf("\n")
383
383
-
}
384
384
-
}
385
385
-
386
386
-
// === MEMPOOL OPERATIONS ===
387
387
-
if len(mempoolOps) > 0 {
388
388
-
fmt.Printf("Mempool Operations (%d total, not yet bundled)\n", len(mempoolOps))
389
389
-
fmt.Printf("══════════════════════════════════════════════════════════════\n\n")
390
390
-
391
391
-
for i, op := range mempoolOps {
392
392
-
status := "✓ Active"
393
393
-
statusSymbol := "✓"
394
394
-
if op.IsNullified() {
395
395
-
status = "✗ Nullified"
396
396
-
statusSymbol = "✗"
397
397
-
}
398
398
-
399
399
-
fmt.Printf("%s Operation %d [Mempool]\n", statusSymbol, i+1)
400
400
-
fmt.Printf(" CID: %s\n", op.CID)
401
401
-
fmt.Printf(" Created: %s\n", op.CreatedAt.Format("2006-01-02 15:04:05.000 MST"))
402
402
-
fmt.Printf(" Status: %s\n", status)
403
403
-
404
404
-
if op.IsNullified() {
405
405
-
if nullCID := op.GetNullifyingCID(); nullCID != "" {
406
406
-
fmt.Printf(" Nullified: %s\n", nullCID)
407
407
-
}
408
408
-
}
409
409
-
410
410
-
// Show operation type if verbose
411
411
-
if *verbose {
412
412
-
if opData, err := op.GetOperationData(); err == nil && opData != nil {
413
413
-
if opType, ok := opData["type"].(string); ok {
414
414
-
fmt.Printf(" Type: %s\n", opType)
415
415
-
}
416
416
-
417
417
-
// Show handle
418
418
-
if handle, ok := opData["handle"].(string); ok {
419
419
-
fmt.Printf(" Handle: %s\n", handle)
420
420
-
} else if aka, ok := opData["alsoKnownAs"].([]interface{}); ok && len(aka) > 0 {
421
421
-
if akaStr, ok := aka[0].(string); ok {
422
422
-
handle := strings.TrimPrefix(akaStr, "at://")
423
423
-
fmt.Printf(" Handle: %s\n", handle)
424
424
-
}
425
425
-
}
426
426
-
}
427
427
-
}
428
428
-
429
429
-
fmt.Printf("\n")
430
430
-
}
431
431
-
}
432
432
-
433
433
-
// === TIMELINE (if multiple operations) ===
434
434
-
if totalOps > 1 && !*verbose {
435
435
-
fmt.Printf("Timeline\n")
436
436
-
fmt.Printf("────────\n")
437
437
-
438
438
-
allTimes := make([]time.Time, 0, totalOps)
439
439
-
for _, owl := range opsWithLoc {
440
440
-
allTimes = append(allTimes, owl.Operation.CreatedAt)
441
441
-
}
442
442
-
for _, op := range mempoolOps {
443
443
-
allTimes = append(allTimes, op.CreatedAt)
444
444
-
}
445
445
-
446
446
-
if len(allTimes) > 0 {
447
447
-
firstTime := allTimes[0]
448
448
-
lastTime := allTimes[len(allTimes)-1]
449
449
-
timespan := lastTime.Sub(firstTime)
450
450
-
451
451
-
fmt.Printf(" First operation: %s\n", firstTime.Format("2006-01-02 15:04:05"))
452
452
-
fmt.Printf(" Latest operation: %s\n", lastTime.Format("2006-01-02 15:04:05"))
453
453
-
fmt.Printf(" Timespan: %s\n", formatDuration(timespan))
454
454
-
fmt.Printf(" Activity age: %s ago\n", formatDuration(time.Since(lastTime)))
455
455
-
}
456
456
-
fmt.Printf("\n")
457
457
-
}
458
458
-
459
459
-
// === FINAL SUMMARY ===
460
460
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
461
461
-
fmt.Printf("✓ Lookup complete in %s\n", totalElapsed)
462
462
-
if stats["exists"].(bool) {
463
463
-
fmt.Printf(" Method: DID index (fast)\n")
464
464
-
} else {
465
465
-
fmt.Printf(" Method: Full scan (slow)\n")
466
466
-
}
467
467
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
468
468
-
}
469
469
-
470
470
-
func cmdDIDIndexResolve() {
471
471
-
if len(os.Args) < 4 {
472
472
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle index resolve <did> [-v]\n")
473
473
-
os.Exit(1)
474
474
-
}
475
475
-
476
476
-
fs := flag.NewFlagSet("index resolve", flag.ExitOnError)
477
477
-
//verbose := fs.Bool("v", false, "verbose debug output")
478
478
-
fs.Parse(os.Args[3:])
479
479
-
480
480
-
if fs.NArg() < 1 {
481
481
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle index resolve <did> [-v]\n")
482
482
-
os.Exit(1)
483
483
-
}
484
484
-
485
485
-
did := fs.Arg(0)
486
486
-
487
487
-
mgr, _, err := getManager("")
488
488
-
if err != nil {
489
489
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
490
490
-
os.Exit(1)
491
491
-
}
492
492
-
defer mgr.Close()
493
493
-
494
494
-
ctx := context.Background()
495
495
-
fmt.Fprintf(os.Stderr, "Resolving: %s\n", did)
496
496
-
497
497
-
start := time.Now()
498
498
-
499
499
-
// ✨ STEP 0: Check mempool first (most recent data)
500
500
-
mempoolStart := time.Now()
501
501
-
var latestOp *plcclient.PLCOperation
502
502
-
foundInMempool := false
503
503
-
504
504
-
if mgr.GetMempool() != nil {
505
505
-
mempoolOps, err := mgr.GetMempoolOperations()
506
506
-
if err == nil && len(mempoolOps) > 0 {
507
507
-
// Search backward for this DID
508
508
-
for i := len(mempoolOps) - 1; i >= 0; i-- {
509
509
-
if mempoolOps[i].DID == did && !mempoolOps[i].IsNullified() {
510
510
-
latestOp = &mempoolOps[i]
511
511
-
foundInMempool = true
512
512
-
break
513
513
-
}
514
514
-
}
515
515
-
}
516
516
-
}
517
517
-
mempoolTime := time.Since(mempoolStart)
518
518
-
519
519
-
if foundInMempool {
520
520
-
fmt.Fprintf(os.Stderr, "Mempool check: %s (✓ found in mempool)\n", mempoolTime)
521
521
-
522
522
-
// Build document from mempool operation
523
523
-
ops := []plcclient.PLCOperation{*latestOp}
524
524
-
doc, err := plcclient.ResolveDIDDocument(did, ops)
525
525
-
if err != nil {
526
526
-
fmt.Fprintf(os.Stderr, "Build document failed: %v\n", err)
527
527
-
os.Exit(1)
528
528
-
}
529
529
-
530
530
-
totalTime := time.Since(start)
531
531
-
fmt.Fprintf(os.Stderr, "Total: %s (resolved from mempool)\n\n", totalTime)
532
532
-
533
533
-
// Output to stdout
534
534
-
data, _ := json.MarshalIndent(doc, "", " ")
535
535
-
fmt.Println(string(data))
536
536
-
return
537
537
-
}
538
538
-
539
539
-
fmt.Fprintf(os.Stderr, "Mempool check: %s (not found)\n", mempoolTime)
540
540
-
541
541
-
// Not in mempool - check index
542
542
-
stats := mgr.GetDIDIndexStats()
543
543
-
if !stats["exists"].(bool) {
544
544
-
fmt.Fprintf(os.Stderr, "⚠️ DID index does not exist. Run: plcbundle index build\n\n")
545
545
-
os.Exit(1)
546
546
-
}
547
547
-
548
548
-
// STEP 1: Index lookup timing
549
549
-
indexStart := time.Now()
550
550
-
locations, err := mgr.GetDIDIndex().GetDIDLocations(did)
551
551
-
if err != nil {
552
552
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
553
553
-
os.Exit(1)
554
554
-
}
555
555
-
indexTime := time.Since(indexStart)
556
556
-
557
557
-
if len(locations) == 0 {
558
558
-
fmt.Fprintf(os.Stderr, "DID not found in index or mempool\n")
559
559
-
os.Exit(1)
560
560
-
}
561
561
-
562
562
-
// Find latest non-nullified location
563
563
-
var latestLoc *didindex.OpLocation
564
564
-
for i := range locations {
565
565
-
if locations[i].Nullified {
566
566
-
continue
567
567
-
}
568
568
-
if latestLoc == nil ||
569
569
-
locations[i].Bundle > latestLoc.Bundle ||
570
570
-
(locations[i].Bundle == latestLoc.Bundle && locations[i].Position > latestLoc.Position) {
571
571
-
latestLoc = &locations[i]
572
572
-
}
573
573
-
}
574
574
-
575
575
-
if latestLoc == nil {
576
576
-
fmt.Fprintf(os.Stderr, "No valid operations (all nullified)\n")
577
577
-
os.Exit(1)
578
578
-
}
579
579
-
580
580
-
fmt.Fprintf(os.Stderr, "Index lookup: %s (shard access)\n", indexTime)
581
581
-
582
582
-
// STEP 2: Operation loading timing (single op, not full bundle!)
583
583
-
opStart := time.Now()
584
584
-
op, err := mgr.LoadOperation(ctx, int(latestLoc.Bundle), int(latestLoc.Position))
585
585
-
if err != nil {
586
586
-
fmt.Fprintf(os.Stderr, "Error loading operation: %v\n", err)
587
587
-
os.Exit(1)
588
588
-
}
589
589
-
opTime := time.Since(opStart)
590
590
-
591
591
-
fmt.Fprintf(os.Stderr, "Operation load: %s (bundle %d, pos %d)\n",
592
592
-
opTime, latestLoc.Bundle, latestLoc.Position)
593
593
-
594
594
-
// STEP 3: Build DID document
595
595
-
ops := []plcclient.PLCOperation{*op}
596
596
-
doc, err := plcclient.ResolveDIDDocument(did, ops)
597
597
-
if err != nil {
598
598
-
fmt.Fprintf(os.Stderr, "Build document failed: %v\n", err)
599
599
-
os.Exit(1)
600
600
-
}
601
601
-
602
602
-
totalTime := time.Since(start)
603
603
-
fmt.Fprintf(os.Stderr, "Total: %s\n\n", totalTime)
604
604
-
605
605
-
// Output to stdout
606
606
-
data, _ := json.MarshalIndent(doc, "", " ")
607
607
-
fmt.Println(string(data))
608
608
-
609
609
-
}
-52
cmd/plcbundle/get_op.go
···
1
1
-
package main
2
2
-
3
3
-
import (
4
4
-
"context"
5
5
-
"fmt"
6
6
-
"os"
7
7
-
"strconv"
8
8
-
9
9
-
"github.com/goccy/go-json"
10
10
-
)
11
11
-
12
12
-
func cmdGetOp() {
13
13
-
if len(os.Args) < 4 {
14
14
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle get-op <bundle> <position>\n")
15
15
-
fmt.Fprintf(os.Stderr, "Example: plcbundle get-op 42 1337\n")
16
16
-
os.Exit(1)
17
17
-
}
18
18
-
19
19
-
bundleNum, err := strconv.Atoi(os.Args[2])
20
20
-
if err != nil {
21
21
-
fmt.Fprintf(os.Stderr, "Error: invalid bundle number\n")
22
22
-
os.Exit(1)
23
23
-
}
24
24
-
25
25
-
position, err := strconv.Atoi(os.Args[3])
26
26
-
if err != nil {
27
27
-
fmt.Fprintf(os.Stderr, "Error: invalid position\n")
28
28
-
os.Exit(1)
29
29
-
}
30
30
-
31
31
-
mgr, _, err := getManager("")
32
32
-
if err != nil {
33
33
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
34
34
-
os.Exit(1)
35
35
-
}
36
36
-
defer mgr.Close()
37
37
-
38
38
-
ctx := context.Background()
39
39
-
op, err := mgr.LoadOperation(ctx, bundleNum, position)
40
40
-
if err != nil {
41
41
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
42
42
-
os.Exit(1)
43
43
-
}
44
44
-
45
45
-
// Output JSON
46
46
-
if len(op.RawJSON) > 0 {
47
47
-
fmt.Println(string(op.RawJSON))
48
48
-
} else {
49
49
-
data, _ := json.Marshal(op)
50
50
-
fmt.Println(string(data))
51
51
-
}
52
52
-
}
+146
-104
cmd/plcbundle/info.go
cmd/plcbundle/commands/info.go
···
1
1
-
package main
1
1
+
package commands
2
2
3
3
import (
4
4
"context"
5
5
+
"flag"
5
6
"fmt"
6
6
-
"os"
7
7
"path/filepath"
8
8
"sort"
9
9
"strings"
···
14
14
"tangled.org/atscan.net/plcbundle/internal/types"
15
15
)
16
16
17
17
-
func showGeneralInfo(mgr *bundle.Manager, dir string, verbose bool, showBundles bool, verify bool, showTimeline bool) {
17
17
+
// InfoCommand handles the info subcommand
18
18
+
func InfoCommand(args []string) error {
19
19
+
fs := flag.NewFlagSet("info", flag.ExitOnError)
20
20
+
bundleNum := fs.Int("bundle", 0, "specific bundle info (0 = general info)")
21
21
+
verbose := fs.Bool("v", false, "verbose output")
22
22
+
showBundles := fs.Bool("bundles", false, "show bundle list")
23
23
+
verify := fs.Bool("verify", false, "verify chain integrity")
24
24
+
showTimeline := fs.Bool("timeline", false, "show timeline visualization")
25
25
+
26
26
+
if err := fs.Parse(args); err != nil {
27
27
+
return err
28
28
+
}
29
29
+
30
30
+
mgr, dir, err := getManager("")
31
31
+
if err != nil {
32
32
+
return err
33
33
+
}
34
34
+
defer mgr.Close()
35
35
+
36
36
+
if *bundleNum > 0 {
37
37
+
return showBundleInfo(mgr, dir, *bundleNum, *verbose)
38
38
+
}
39
39
+
40
40
+
return showGeneralInfo(mgr, dir, *verbose, *showBundles, *verify, *showTimeline)
41
41
+
}
42
42
+
43
43
+
func showGeneralInfo(mgr *bundle.Manager, dir string, verbose, showBundles, verify, showTimeline bool) error {
18
44
index := mgr.GetIndex()
19
45
info := mgr.GetInfo()
20
46
stats := index.GetStats()
21
47
bundleCount := stats["bundle_count"].(int)
22
48
23
23
-
fmt.Printf("\n")
24
24
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
49
49
+
fmt.Printf("\n═══════════════════════════════════════════════════════════════\n")
25
50
fmt.Printf(" PLC Bundle Repository Overview\n")
26
26
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
27
27
-
fmt.Printf("\n")
51
51
+
fmt.Printf("═══════════════════════════════════════════════════════════════\n\n")
28
52
29
29
-
// Location
30
53
fmt.Printf("📁 Location\n")
31
54
fmt.Printf(" Directory: %s\n", dir)
32
32
-
fmt.Printf(" Index: %s\n", filepath.Base(info["index_path"].(string)))
33
33
-
fmt.Printf("\n")
55
55
+
fmt.Printf(" Index: %s\n\n", filepath.Base(info["index_path"].(string)))
56
56
+
34
57
fmt.Printf("🌐 Origin\n")
35
35
-
fmt.Printf(" Source: %s\n", index.Origin)
36
36
-
fmt.Printf("\n")
58
58
+
fmt.Printf(" Source: %s\n\n", index.Origin)
37
59
38
60
if bundleCount == 0 {
39
39
-
fmt.Printf("⚠️ No bundles found\n")
40
40
-
fmt.Printf("\n")
61
61
+
fmt.Printf("⚠️ No bundles found\n\n")
41
62
fmt.Printf("Get started:\n")
42
63
fmt.Printf(" plcbundle fetch # Fetch bundles from PLC\n")
43
43
-
fmt.Printf(" plcbundle rebuild # Rebuild index from existing files\n")
44
44
-
fmt.Printf("\n")
45
45
-
return
64
64
+
fmt.Printf(" plcbundle rebuild # Rebuild index from existing files\n\n")
65
65
+
return nil
46
66
}
47
67
48
68
firstBundle := stats["first_bundle"].(int)
49
69
lastBundle := stats["last_bundle"].(int)
50
70
totalCompressedSize := stats["total_size"].(int64)
71
71
+
totalUncompressedSize := stats["total_uncompressed_size"].(int64)
51
72
startTime := stats["start_time"].(time.Time)
52
73
endTime := stats["end_time"].(time.Time)
53
74
updatedAt := stats["updated_at"].(time.Time)
54
75
55
55
-
// Calculate total uncompressed size
56
56
-
bundles := index.GetBundles()
57
57
-
var totalUncompressedSize int64
58
58
-
for _, meta := range bundles {
59
59
-
totalUncompressedSize += meta.UncompressedSize
60
60
-
}
61
61
-
62
76
// Summary
63
77
fmt.Printf("📊 Summary\n")
64
78
fmt.Printf(" Bundles: %s\n", formatNumber(bundleCount))
···
69
83
ratio := float64(totalUncompressedSize) / float64(totalCompressedSize)
70
84
fmt.Printf(" Ratio: %.2fx compression\n", ratio)
71
85
}
72
72
-
fmt.Printf(" Avg/Bundle: %s\n", formatBytes(totalCompressedSize/int64(bundleCount)))
73
73
-
fmt.Printf("\n")
86
86
+
fmt.Printf(" Avg/Bundle: %s\n\n", formatBytes(totalCompressedSize/int64(bundleCount)))
74
87
75
88
// Timeline
76
89
duration := endTime.Sub(startTime)
···
79
92
fmt.Printf(" Last Op: %s\n", endTime.Format("2006-01-02 15:04:05 MST"))
80
93
fmt.Printf(" Timespan: %s\n", formatDuration(duration))
81
94
fmt.Printf(" Last Updated: %s\n", updatedAt.Format("2006-01-02 15:04:05 MST"))
82
82
-
fmt.Printf(" Age: %s ago\n", formatDuration(time.Since(updatedAt)))
83
83
-
fmt.Printf("\n")
95
95
+
fmt.Printf(" Age: %s ago\n\n", formatDuration(time.Since(updatedAt)))
84
96
85
85
-
// Operations count (exact calculation)
97
97
+
// Operations
86
98
mempoolStats := mgr.GetMempoolStats()
87
99
mempoolCount := mempoolStats["count"].(int)
88
100
bundleOpsCount := bundleCount * types.BUNDLE_SIZE
···
100
112
}
101
113
fmt.Printf("\n")
102
114
103
103
-
// Hashes (full, not trimmed)
115
115
+
// Hashes
104
116
firstMeta, err := index.GetBundle(firstBundle)
105
117
if err == nil {
106
118
fmt.Printf("🔐 Chain Hashes\n")
···
154
166
fmt.Printf(" Operations: %s / %s\n", formatNumber(mempoolCount), formatNumber(types.BUNDLE_SIZE))
155
167
fmt.Printf(" Progress: %.1f%%\n", progress)
156
168
157
157
-
// Progress bar
158
169
barWidth := 40
159
170
filled := int(float64(barWidth) * float64(mempoolCount) / float64(types.BUNDLE_SIZE))
160
171
if filled > barWidth {
···
185
196
fmt.Printf(" ✓ Chain is valid\n")
186
197
fmt.Printf(" ✓ All %d bundles verified\n", len(result.VerifiedBundles))
187
198
188
188
-
// Show head hash (full)
189
199
lastMeta, _ := index.GetBundle(lastBundle)
190
200
if lastMeta != nil {
191
201
fmt.Printf(" Head: %s\n", lastMeta.Hash)
···
199
209
fmt.Printf("\n")
200
210
}
201
211
202
202
-
// Timeline visualization
212
212
+
// Timeline
203
213
if showTimeline {
204
214
fmt.Printf("📈 Timeline Visualization\n")
205
215
visualizeTimeline(index, verbose)
···
209
219
// Bundle list
210
220
if showBundles {
211
221
bundles := index.GetBundles()
212
212
-
fmt.Printf("📚 Bundle List (%d total)\n", len(bundles))
213
213
-
fmt.Printf("\n")
222
222
+
fmt.Printf("📚 Bundle List (%d total)\n\n", len(bundles))
214
223
fmt.Printf(" Number | Start Time | End Time | Ops | DIDs | Size\n")
215
224
fmt.Printf(" ---------|---------------------|---------------------|--------|--------|--------\n")
216
225
···
227
236
} else if bundleCount > 0 {
228
237
fmt.Printf("💡 Tip: Use --bundles to see detailed bundle list\n")
229
238
fmt.Printf(" Use --timeline to see timeline visualization\n")
230
230
-
fmt.Printf(" Use --verify to verify chain integrity\n")
231
231
-
fmt.Printf("\n")
239
239
+
fmt.Printf(" Use --verify to verify chain integrity\n\n")
232
240
}
233
241
234
234
-
// File system stats (verbose)
235
235
-
if verbose {
236
236
-
fmt.Printf("💾 File System\n")
242
242
+
return nil
243
243
+
}
237
244
238
238
-
// Calculate average compression ratio
239
239
-
if totalCompressedSize > 0 && totalUncompressedSize > 0 {
240
240
-
avgRatio := float64(totalUncompressedSize) / float64(totalCompressedSize)
241
241
-
savings := (1 - float64(totalCompressedSize)/float64(totalUncompressedSize)) * 100
242
242
-
fmt.Printf(" Compression: %.2fx average ratio\n", avgRatio)
243
243
-
fmt.Printf(" Space Saved: %.1f%% (%s)\n", savings, formatBytes(totalUncompressedSize-totalCompressedSize))
244
244
-
}
245
245
+
func showBundleInfo(mgr *bundle.Manager, dir string, bundleNum int, verbose bool) error {
246
246
+
ctx := context.Background()
247
247
+
b, err := mgr.LoadBundle(ctx, bundleNum)
248
248
+
if err != nil {
249
249
+
return err
250
250
+
}
245
251
246
246
-
// Index size
247
247
-
indexPath := info["index_path"].(string)
248
248
-
if indexInfo, err := os.Stat(indexPath); err == nil {
249
249
-
fmt.Printf(" Index Size: %s\n", formatBytes(indexInfo.Size()))
252
252
+
fmt.Printf("\n═══════════════════════════════════════════════════════════════\n")
253
253
+
fmt.Printf(" Bundle %06d\n", b.BundleNumber)
254
254
+
fmt.Printf("═══════════════════════════════════════════════════════════════\n\n")
255
255
+
256
256
+
fmt.Printf("📁 Location\n")
257
257
+
fmt.Printf(" Directory: %s\n", dir)
258
258
+
fmt.Printf(" File: %06d.jsonl.zst\n\n", b.BundleNumber)
259
259
+
260
260
+
duration := b.EndTime.Sub(b.StartTime)
261
261
+
fmt.Printf("📅 Time Range\n")
262
262
+
fmt.Printf(" Start: %s\n", b.StartTime.Format("2006-01-02 15:04:05.000 MST"))
263
263
+
fmt.Printf(" End: %s\n", b.EndTime.Format("2006-01-02 15:04:05.000 MST"))
264
264
+
fmt.Printf(" Duration: %s\n", formatDuration(duration))
265
265
+
fmt.Printf(" Created: %s\n\n", b.CreatedAt.Format("2006-01-02 15:04:05 MST"))
266
266
+
267
267
+
fmt.Printf("📊 Content\n")
268
268
+
fmt.Printf(" Operations: %s\n", formatNumber(len(b.Operations)))
269
269
+
fmt.Printf(" Unique DIDs: %s\n", formatNumber(b.DIDCount))
270
270
+
if len(b.Operations) > 0 && b.DIDCount > 0 {
271
271
+
avgOpsPerDID := float64(len(b.Operations)) / float64(b.DIDCount)
272
272
+
fmt.Printf(" Avg ops/DID: %.2f\n", avgOpsPerDID)
273
273
+
}
274
274
+
fmt.Printf("\n")
275
275
+
276
276
+
fmt.Printf("💾 Size\n")
277
277
+
fmt.Printf(" Compressed: %s\n", formatBytes(b.CompressedSize))
278
278
+
fmt.Printf(" Uncompressed: %s\n", formatBytes(b.UncompressedSize))
279
279
+
fmt.Printf(" Ratio: %.2fx\n", b.CompressionRatio())
280
280
+
fmt.Printf(" Efficiency: %.1f%% savings\n\n", (1-float64(b.CompressedSize)/float64(b.UncompressedSize))*100)
281
281
+
282
282
+
fmt.Printf("🔐 Cryptographic Hashes\n")
283
283
+
fmt.Printf(" Chain Hash:\n %s\n", b.Hash)
284
284
+
fmt.Printf(" Content Hash:\n %s\n", b.ContentHash)
285
285
+
fmt.Printf(" Compressed:\n %s\n", b.CompressedHash)
286
286
+
if b.Parent != "" {
287
287
+
fmt.Printf(" Parent Chain Hash:\n %s\n", b.Parent)
288
288
+
}
289
289
+
fmt.Printf("\n")
290
290
+
291
291
+
if verbose && len(b.Operations) > 0 {
292
292
+
showBundleSamples(b)
293
293
+
showBundleDIDStats(b)
294
294
+
}
295
295
+
296
296
+
return nil
297
297
+
}
298
298
+
299
299
+
func showBundleSamples(b *bundle.Bundle) {
300
300
+
fmt.Printf("📝 Sample Operations (first 5)\n")
301
301
+
showCount := 5
302
302
+
if len(b.Operations) < showCount {
303
303
+
showCount = len(b.Operations)
304
304
+
}
305
305
+
306
306
+
for i := 0; i < showCount; i++ {
307
307
+
op := b.Operations[i]
308
308
+
fmt.Printf(" %d. %s\n", i+1, op.DID)
309
309
+
fmt.Printf(" CID: %s\n", op.CID)
310
310
+
fmt.Printf(" Time: %s\n", op.CreatedAt.Format("2006-01-02 15:04:05.000"))
311
311
+
if op.IsNullified() {
312
312
+
fmt.Printf(" ⚠️ Nullified: %s\n", op.GetNullifyingCID())
250
313
}
314
314
+
}
315
315
+
fmt.Printf("\n")
316
316
+
}
317
317
+
318
318
+
func showBundleDIDStats(b *bundle.Bundle) {
319
319
+
didOps := make(map[string]int)
320
320
+
for _, op := range b.Operations {
321
321
+
didOps[op.DID]++
322
322
+
}
323
323
+
324
324
+
type didCount struct {
325
325
+
did string
326
326
+
count int
327
327
+
}
328
328
+
329
329
+
var counts []didCount
330
330
+
for did, count := range didOps {
331
331
+
counts = append(counts, didCount{did, count})
332
332
+
}
333
333
+
334
334
+
sort.Slice(counts, func(i, j int) bool {
335
335
+
return counts[i].count > counts[j].count
336
336
+
})
251
337
252
252
-
fmt.Printf("\n")
338
338
+
fmt.Printf("🏆 Most Active DIDs\n")
339
339
+
showCount := 5
340
340
+
if len(counts) < showCount {
341
341
+
showCount = len(counts)
253
342
}
343
343
+
344
344
+
for i := 0; i < showCount; i++ {
345
345
+
fmt.Printf(" %d. %s (%d ops)\n", i+1, counts[i].did, counts[i].count)
346
346
+
}
347
347
+
fmt.Printf("\n")
254
348
}
255
349
256
350
func visualizeTimeline(index *bundleindex.Index, verbose bool) {
···
259
353
return
260
354
}
261
355
262
262
-
// Group bundles by date
263
356
type dateGroup struct {
264
357
date string
265
358
count int
···
283
376
}
284
377
}
285
378
286
286
-
// Sort dates
287
379
var dates []string
288
380
for date := range dateMap {
289
381
dates = append(dates, date)
290
382
}
291
383
sort.Strings(dates)
292
384
293
293
-
// Find max count for scaling
294
385
maxCount := 0
295
386
for _, group := range dateMap {
296
387
if group.count > maxCount {
···
298
389
}
299
390
}
300
391
301
301
-
// Display
302
392
fmt.Printf("\n")
303
393
barWidth := 40
304
394
for _, date := range dates {
···
316
406
fmt.Printf("\n")
317
407
}
318
408
}
319
319
-
320
320
-
// Helper formatting functions
321
321
-
322
322
-
func formatNumber(n int) string {
323
323
-
s := fmt.Sprintf("%d", n)
324
324
-
// Add thousand separators
325
325
-
var result []byte
326
326
-
for i, c := range s {
327
327
-
if i > 0 && (len(s)-i)%3 == 0 {
328
328
-
result = append(result, ',')
329
329
-
}
330
330
-
result = append(result, byte(c))
331
331
-
}
332
332
-
return string(result)
333
333
-
}
334
334
-
335
335
-
func formatBytes(bytes int64) string {
336
336
-
const unit = 1000
337
337
-
if bytes < unit {
338
338
-
return fmt.Sprintf("%d B", bytes)
339
339
-
}
340
340
-
div, exp := int64(unit), 0
341
341
-
for n := bytes / unit; n >= unit; n /= unit {
342
342
-
div *= unit
343
343
-
exp++
344
344
-
}
345
345
-
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
346
346
-
}
347
347
-
348
348
-
func formatDuration(d time.Duration) string {
349
349
-
if d < time.Minute {
350
350
-
return fmt.Sprintf("%.0f seconds", d.Seconds())
351
351
-
}
352
352
-
if d < time.Hour {
353
353
-
return fmt.Sprintf("%.1f minutes", d.Minutes())
354
354
-
}
355
355
-
if d < 24*time.Hour {
356
356
-
return fmt.Sprintf("%.1f hours", d.Hours())
357
357
-
}
358
358
-
days := d.Hours() / 24
359
359
-
if days < 30 {
360
360
-
return fmt.Sprintf("%.1f days", days)
361
361
-
}
362
362
-
if days < 365 {
363
363
-
return fmt.Sprintf("%.1f months", days/30)
364
364
-
}
365
365
-
return fmt.Sprintf("%.1f years", days/365)
366
366
-
}
+30
-1472
cmd/plcbundle/main.go
···
1
1
package main
2
2
3
3
import (
4
4
-
"context"
5
5
-
"flag"
6
4
"fmt"
7
7
-
"net/http"
8
5
"os"
9
9
-
"os/signal"
10
10
-
"path/filepath"
11
11
-
"runtime"
12
6
"runtime/debug"
13
13
-
"sort"
14
14
-
"strings"
15
15
-
"sync"
16
16
-
"syscall"
17
17
-
"time"
18
7
19
19
-
"github.com/goccy/go-json"
20
20
-
21
21
-
"tangled.org/atscan.net/plcbundle/internal/bundle"
22
22
-
"tangled.org/atscan.net/plcbundle/internal/bundleindex"
23
23
-
"tangled.org/atscan.net/plcbundle/internal/didindex"
24
24
-
internalsync "tangled.org/atscan.net/plcbundle/internal/sync"
25
25
-
"tangled.org/atscan.net/plcbundle/internal/types"
26
26
-
"tangled.org/atscan.net/plcbundle/plcclient"
8
8
+
"tangled.org/atscan.net/plcbundle/cmd/plcbundle/commands"
27
9
)
28
10
29
29
-
// Version information (injected at build time via ldflags or read from build info)
30
30
-
var (
31
31
-
version = "dev"
32
32
-
gitCommit = "unknown"
33
33
-
buildDate = "unknown"
34
34
-
)
35
35
-
36
36
-
func init() {
37
37
-
// Try to get version from build info (works with go install)
38
38
-
if info, ok := debug.ReadBuildInfo(); ok {
39
39
-
if info.Main.Version != "" && info.Main.Version != "(devel)" {
40
40
-
version = info.Main.Version
41
41
-
}
42
42
-
43
43
-
// Extract git commit and build time from build settings
44
44
-
for _, setting := range info.Settings {
45
45
-
switch setting.Key {
46
46
-
case "vcs.revision":
47
47
-
if setting.Value != "" {
48
48
-
gitCommit = setting.Value
49
49
-
if len(gitCommit) > 7 {
50
50
-
gitCommit = gitCommit[:7] // Short hash
51
51
-
}
52
52
-
}
53
53
-
case "vcs.time":
54
54
-
if setting.Value != "" {
55
55
-
buildDate = setting.Value
56
56
-
}
57
57
-
}
58
58
-
}
59
59
-
}
60
60
-
}
61
61
-
62
11
func main() {
63
63
-
64
12
debug.SetGCPercent(400)
65
13
66
14
if len(os.Args) < 2 {
···
70
18
71
19
command := os.Args[1]
72
20
21
21
+
var err error
73
22
switch command {
74
23
case "fetch":
75
75
-
cmdFetch()
24
24
+
err = commands.FetchCommand(os.Args[2:])
76
25
case "clone":
77
77
-
cmdClone()
26
26
+
err = commands.CloneCommand(os.Args[2:])
78
27
case "rebuild":
79
79
-
cmdRebuild()
28
28
+
err = commands.RebuildCommand(os.Args[2:])
80
29
case "verify":
81
81
-
cmdVerify()
30
30
+
err = commands.VerifyCommand(os.Args[2:])
82
31
case "info":
83
83
-
cmdInfo()
32
32
+
err = commands.InfoCommand(os.Args[2:])
84
33
case "export":
85
85
-
cmdExport()
34
34
+
err = commands.ExportCommand(os.Args[2:])
86
35
case "backfill":
87
87
-
cmdBackfill()
36
36
+
err = commands.BackfillCommand(os.Args[2:])
88
37
case "mempool":
89
89
-
cmdMempool()
38
38
+
err = commands.MempoolCommand(os.Args[2:])
90
39
case "serve":
91
91
-
cmdServe()
40
40
+
err = commands.ServerCommand(os.Args[2:])
92
41
case "compare":
93
93
-
cmdCompare()
42
42
+
err = commands.CompareCommand(os.Args[2:])
94
43
case "detector":
95
95
-
cmdDetector()
44
44
+
err = commands.DetectorCommand(os.Args[2:])
96
45
case "index":
97
97
-
cmdDIDIndex()
46
46
+
err = commands.IndexCommand(os.Args[2:])
98
47
case "get-op":
99
99
-
cmdGetOp()
48
48
+
err = commands.GetOpCommand(os.Args[2:])
100
49
case "version":
101
101
-
fmt.Printf("plcbundle version %s\n", version)
102
102
-
fmt.Printf(" commit: %s\n", gitCommit)
103
103
-
fmt.Printf(" built: %s\n", buildDate)
50
50
+
err = commands.VersionCommand(os.Args[2:])
104
51
default:
105
52
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
106
53
printUsage()
107
54
os.Exit(1)
108
55
}
56
56
+
57
57
+
if err != nil {
58
58
+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
59
59
+
os.Exit(1)
60
60
+
}
109
61
}
110
62
111
63
func printUsage() {
···
116
68
117
69
Commands:
118
70
fetch Fetch next bundle from PLC directory
119
119
-
clone Clone bundles from remote HTTP endpoint
71
71
+
clone Clone bundles from remote HTTP endpoint
120
72
rebuild Rebuild index from existing bundle files
121
73
verify Verify bundle integrity
122
74
info Show bundle information
···
127
79
compare Compare local index with target index
128
80
detector Run spam detectors
129
81
index Manage DID position index
82
82
+
get-op Get specific operation by bundle and position
130
83
version Show version
131
84
132
132
-
Security Model:
133
133
-
Bundles are cryptographically chained but require external verification:
134
134
-
- Verify against original PLC directory
135
135
-
- Compare with multiple independent mirrors
136
136
-
- Check published root and head hashes
137
137
-
- Anyone can reproduce bundles from PLC directory
138
138
-
139
139
-
`, version)
140
140
-
}
141
141
-
142
142
-
// getManager creates or opens a bundle manager in the detected directory
143
143
-
func getManager(plcURL string) (*bundle.Manager, string, error) {
144
144
-
dir, err := os.Getwd()
145
145
-
if err != nil {
146
146
-
return nil, "", err
147
147
-
}
148
148
-
149
149
-
// Ensure directory exists
150
150
-
if err := os.MkdirAll(dir, 0755); err != nil {
151
151
-
return nil, "", fmt.Errorf("failed to create directory: %w", err)
152
152
-
}
153
153
-
154
154
-
config := bundle.DefaultConfig(dir)
155
155
-
156
156
-
var client *plcclient.Client
157
157
-
if plcURL != "" {
158
158
-
client = plcclient.NewClient(plcURL)
159
159
-
}
160
160
-
161
161
-
mgr, err := bundle.NewManager(config, client)
162
162
-
if err != nil {
163
163
-
return nil, "", err
164
164
-
}
165
165
-
166
166
-
return mgr, dir, nil
167
167
-
}
168
168
-
169
169
-
func cmdFetch() {
170
170
-
fs := flag.NewFlagSet("fetch", flag.ExitOnError)
171
171
-
plcURL := fs.String("plc", "https://plc.directory", "PLC directory URL")
172
172
-
count := fs.Int("count", 0, "number of bundles to fetch (0 = fetch all available)")
173
173
-
verbose := fs.Bool("verbose", false, "verbose sync logging")
174
174
-
fs.Parse(os.Args[2:])
175
175
-
176
176
-
mgr, dir, err := getManager(*plcURL)
177
177
-
if err != nil {
178
178
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
179
179
-
os.Exit(1)
180
180
-
}
181
181
-
defer mgr.Close()
182
182
-
183
183
-
fmt.Printf("Working in: %s\n", dir)
184
184
-
185
185
-
ctx := context.Background()
186
186
-
187
187
-
// Get starting bundle info
188
188
-
index := mgr.GetIndex()
189
189
-
lastBundle := index.GetLastBundle()
190
190
-
startBundle := 1
191
191
-
if lastBundle != nil {
192
192
-
startBundle = lastBundle.BundleNumber + 1
193
193
-
}
194
194
-
195
195
-
fmt.Printf("Starting from bundle %06d\n", startBundle)
196
196
-
197
197
-
if *count > 0 {
198
198
-
fmt.Printf("Fetching %d bundles...\n", *count)
199
199
-
} else {
200
200
-
fmt.Printf("Fetching all available bundles...\n")
201
201
-
}
202
202
-
203
203
-
fetchedCount := 0
204
204
-
consecutiveErrors := 0
205
205
-
maxConsecutiveErrors := 3
206
206
-
207
207
-
for {
208
208
-
// Check if we've reached the requested count
209
209
-
if *count > 0 && fetchedCount >= *count {
210
210
-
break
211
211
-
}
212
212
-
213
213
-
currentBundle := startBundle + fetchedCount
214
214
-
215
215
-
if *count > 0 {
216
216
-
fmt.Printf("Fetching bundle %d/%d (bundle %06d)...\n", fetchedCount+1, *count, currentBundle)
217
217
-
} else {
218
218
-
fmt.Printf("Fetching bundle %06d...\n", currentBundle)
219
219
-
}
220
220
-
221
221
-
b, err := mgr.FetchNextBundle(ctx, !*verbose)
222
222
-
if err != nil {
223
223
-
// Check if we've reached the end (insufficient operations)
224
224
-
if isEndOfDataError(err) {
225
225
-
fmt.Printf("\n✓ Caught up! No more complete bundles available.\n")
226
226
-
fmt.Printf(" Last bundle: %06d\n", currentBundle-1)
227
227
-
break
228
228
-
}
229
229
-
230
230
-
// Handle other errors
231
231
-
consecutiveErrors++
232
232
-
fmt.Fprintf(os.Stderr, "Error fetching bundle %06d: %v\n", currentBundle, err)
233
233
-
234
234
-
if consecutiveErrors >= maxConsecutiveErrors {
235
235
-
fmt.Fprintf(os.Stderr, "Too many consecutive errors, stopping.\n")
236
236
-
os.Exit(1)
237
237
-
}
238
238
-
239
239
-
// Wait a bit before retrying
240
240
-
fmt.Printf("Waiting 5 seconds before retry...\n")
241
241
-
time.Sleep(5 * time.Second)
242
242
-
continue
243
243
-
}
244
244
-
245
245
-
// Reset error counter on success
246
246
-
consecutiveErrors = 0
247
247
-
248
248
-
if err := mgr.SaveBundle(ctx, b, !*verbose); err != nil {
249
249
-
fmt.Fprintf(os.Stderr, "Error saving bundle %06d: %v\n", b.BundleNumber, err)
250
250
-
os.Exit(1)
251
251
-
}
252
252
-
253
253
-
fetchedCount++
254
254
-
fmt.Printf("✓ Saved bundle %06d (%d operations, %d DIDs)\n",
255
255
-
b.BundleNumber, len(b.Operations), b.DIDCount)
256
256
-
}
257
257
-
258
258
-
if fetchedCount > 0 {
259
259
-
fmt.Printf("\n✓ Fetch complete: %d bundles retrieved\n", fetchedCount)
260
260
-
fmt.Printf(" Current range: %06d - %06d\n", startBundle, startBundle+fetchedCount-1)
261
261
-
} else {
262
262
-
fmt.Printf("\n✓ Already up to date!\n")
263
263
-
}
264
264
-
}
265
265
-
266
266
-
func cmdClone() {
267
267
-
fs := flag.NewFlagSet("clone", flag.ExitOnError)
268
268
-
workers := fs.Int("workers", 4, "number of concurrent download workers")
269
269
-
verbose := fs.Bool("v", false, "verbose output")
270
270
-
skipExisting := fs.Bool("skip-existing", true, "skip bundles that already exist locally")
271
271
-
saveInterval := fs.Duration("save-interval", 5*time.Second, "interval to save index during download")
272
272
-
fs.Parse(os.Args[2:])
273
273
-
274
274
-
if fs.NArg() < 1 {
275
275
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle clone <remote-url> [options]\n")
276
276
-
fmt.Fprintf(os.Stderr, "\nClone bundles from a remote plcbundle HTTP endpoint\n\n")
277
277
-
fmt.Fprintf(os.Stderr, "Options:\n")
278
278
-
fs.PrintDefaults()
279
279
-
fmt.Fprintf(os.Stderr, "\nExample:\n")
280
280
-
fmt.Fprintf(os.Stderr, " plcbundle clone https://plc.example.com\n")
281
281
-
os.Exit(1)
282
282
-
}
283
283
-
284
284
-
remoteURL := strings.TrimSuffix(fs.Arg(0), "/")
285
285
-
286
286
-
// Create manager
287
287
-
mgr, dir, err := getManager("")
288
288
-
if err != nil {
289
289
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
290
290
-
os.Exit(1)
291
291
-
}
292
292
-
defer mgr.Close()
293
293
-
294
294
-
fmt.Printf("Cloning from: %s\n", remoteURL)
295
295
-
fmt.Printf("Target directory: %s\n", dir)
296
296
-
fmt.Printf("Workers: %d\n", *workers)
297
297
-
fmt.Printf("(Press Ctrl+C to safely interrupt - progress will be saved)\n\n")
298
298
-
299
299
-
// Set up signal handling
300
300
-
ctx, cancel := context.WithCancel(context.Background())
301
301
-
defer cancel()
302
302
-
303
303
-
sigChan := make(chan os.Signal, 1)
304
304
-
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
305
305
-
306
306
-
// Set up progress bar
307
307
-
var progress *ProgressBar
308
308
-
var progressMu sync.Mutex
309
309
-
progressActive := true
310
310
-
311
311
-
go func() {
312
312
-
<-sigChan
313
313
-
progressMu.Lock()
314
314
-
progressActive = false
315
315
-
if progress != nil {
316
316
-
fmt.Println()
317
317
-
}
318
318
-
progressMu.Unlock()
319
319
-
320
320
-
fmt.Printf("\n⚠️ Interrupt received! Finishing current downloads and saving progress...\n")
321
321
-
cancel()
322
322
-
}()
323
323
-
324
324
-
// Clone with library
325
325
-
result, err := mgr.CloneFromRemote(ctx, internalsync.CloneOptions{
326
326
-
RemoteURL: remoteURL,
327
327
-
Workers: *workers,
328
328
-
SkipExisting: *skipExisting,
329
329
-
SaveInterval: *saveInterval,
330
330
-
Verbose: *verbose,
331
331
-
ProgressFunc: func(downloaded, total int, bytesDownloaded, bytesTotal int64) {
332
332
-
progressMu.Lock()
333
333
-
defer progressMu.Unlock()
334
334
-
335
335
-
// Stop updating progress if interrupted
336
336
-
if !progressActive {
337
337
-
return
338
338
-
}
339
339
-
340
340
-
if progress == nil {
341
341
-
progress = NewProgressBarWithBytes(total, bytesTotal)
342
342
-
progress.showBytes = true
343
343
-
}
344
344
-
progress.SetWithBytes(downloaded, bytesDownloaded)
345
345
-
},
346
346
-
})
347
347
-
348
348
-
// Ensure progress is stopped
349
349
-
progressMu.Lock()
350
350
-
progressActive = false
351
351
-
if progress != nil {
352
352
-
progress.Finish()
353
353
-
}
354
354
-
progressMu.Unlock()
355
355
-
356
356
-
if err != nil {
357
357
-
fmt.Fprintf(os.Stderr, "Clone failed: %v\n", err)
358
358
-
os.Exit(1)
359
359
-
}
360
360
-
361
361
-
// Display results
362
362
-
if result.Interrupted {
363
363
-
fmt.Printf("⚠️ Download interrupted by user\n")
364
364
-
} else {
365
365
-
fmt.Printf("\n✓ Clone complete in %s\n", result.Duration.Round(time.Millisecond))
366
366
-
}
367
367
-
368
368
-
fmt.Printf("\nResults:\n")
369
369
-
fmt.Printf(" Remote bundles: %d\n", result.RemoteBundles)
370
370
-
if result.Skipped > 0 {
371
371
-
fmt.Printf(" Skipped (existing): %d\n", result.Skipped)
372
372
-
}
373
373
-
fmt.Printf(" Downloaded: %d\n", result.Downloaded)
374
374
-
if result.Failed > 0 {
375
375
-
fmt.Printf(" Failed: %d\n", result.Failed)
376
376
-
}
377
377
-
fmt.Printf(" Total size: %s\n", formatBytes(result.TotalBytes))
378
378
-
379
379
-
if result.Duration.Seconds() > 0 && result.Downloaded > 0 {
380
380
-
mbPerSec := float64(result.TotalBytes) / result.Duration.Seconds() / (1024 * 1024)
381
381
-
bundlesPerSec := float64(result.Downloaded) / result.Duration.Seconds()
382
382
-
fmt.Printf(" Average speed: %.1f MB/s (%.1f bundles/s)\n", mbPerSec, bundlesPerSec)
383
383
-
}
384
384
-
385
385
-
if result.Failed > 0 {
386
386
-
fmt.Printf("\n⚠️ Failed bundles: ")
387
387
-
for i, num := range result.FailedBundles {
388
388
-
if i > 0 {
389
389
-
fmt.Printf(", ")
390
390
-
}
391
391
-
if i > 10 {
392
392
-
fmt.Printf("... and %d more", len(result.FailedBundles)-10)
393
393
-
break
394
394
-
}
395
395
-
fmt.Printf("%06d", num)
396
396
-
}
397
397
-
fmt.Printf("\nRe-run the clone command to retry failed bundles.\n")
398
398
-
os.Exit(1)
399
399
-
}
400
400
-
401
401
-
if result.Interrupted {
402
402
-
fmt.Printf("\n✓ Progress saved. Re-run the clone command to resume.\n")
403
403
-
os.Exit(1)
404
404
-
}
405
405
-
406
406
-
fmt.Printf("\n✓ Clone complete!\n")
407
407
-
}
408
408
-
409
409
-
func cmdRebuild() {
410
410
-
fs := flag.NewFlagSet("rebuild", flag.ExitOnError)
411
411
-
verbose := fs.Bool("v", false, "verbose output")
412
412
-
workers := fs.Int("workers", 4, "number of parallel workers (0 = CPU count)")
413
413
-
noProgress := fs.Bool("no-progress", false, "disable progress bar")
414
414
-
fs.Parse(os.Args[2:])
415
415
-
416
416
-
// Auto-detect CPU count
417
417
-
if *workers == 0 {
418
418
-
*workers = runtime.NumCPU()
419
419
-
}
420
420
-
421
421
-
// Create manager WITHOUT auto-rebuild (we'll do it manually)
422
422
-
dir, err := os.Getwd()
423
423
-
if err != nil {
424
424
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
425
425
-
os.Exit(1)
426
426
-
}
427
427
-
428
428
-
// Ensure directory exists
429
429
-
if err := os.MkdirAll(dir, 0755); err != nil {
430
430
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
431
431
-
os.Exit(1)
432
432
-
}
433
433
-
434
434
-
config := bundle.DefaultConfig(dir)
435
435
-
config.AutoRebuild = false
436
436
-
config.RebuildWorkers = *workers
437
437
-
438
438
-
mgr, err := bundle.NewManager(config, nil)
439
439
-
if err != nil {
440
440
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
441
441
-
os.Exit(1)
442
442
-
}
443
443
-
defer mgr.Close()
444
444
-
445
445
-
fmt.Printf("Rebuilding index from: %s\n", dir)
446
446
-
fmt.Printf("Using %d workers\n", *workers)
447
447
-
448
448
-
// Find all bundle files
449
449
-
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl.zst"))
450
450
-
if err != nil {
451
451
-
fmt.Fprintf(os.Stderr, "Error scanning directory: %v\n", err)
452
452
-
os.Exit(1)
453
453
-
}
454
454
-
455
455
-
if len(files) == 0 {
456
456
-
fmt.Println("No bundle files found")
457
457
-
return
458
458
-
}
459
459
-
460
460
-
fmt.Printf("Found %d bundle files\n", len(files))
461
461
-
fmt.Printf("\n")
462
462
-
463
463
-
start := time.Now()
464
464
-
465
465
-
// Create progress bar
466
466
-
var progress *ProgressBar
467
467
-
var progressCallback func(int, int, int64)
468
468
-
469
469
-
if !*noProgress {
470
470
-
fmt.Println("Processing bundles:")
471
471
-
progress = NewProgressBar(len(files))
472
472
-
progress.showBytes = true // Enable byte tracking
473
473
-
474
474
-
progressCallback = func(current, total int, bytesProcessed int64) {
475
475
-
progress.SetWithBytes(current, bytesProcessed)
476
476
-
}
477
477
-
}
478
478
-
479
479
-
// Use parallel scan
480
480
-
result, err := mgr.ScanDirectoryParallel(*workers, progressCallback)
481
481
-
482
482
-
if err != nil {
483
483
-
if progress != nil {
484
484
-
progress.Finish()
485
485
-
}
486
486
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
487
487
-
os.Exit(1)
488
488
-
}
489
489
-
490
490
-
// Finish progress bar
491
491
-
if progress != nil {
492
492
-
progress.Finish()
493
493
-
}
494
494
-
495
495
-
elapsed := time.Since(start)
496
496
-
497
497
-
fmt.Printf("\n")
498
498
-
fmt.Printf("✓ Index rebuilt in %s\n", elapsed.Round(time.Millisecond))
499
499
-
fmt.Printf(" Total bundles: %d\n", result.BundleCount)
500
500
-
fmt.Printf(" Compressed size: %s\n", formatBytes(result.TotalSize))
501
501
-
fmt.Printf(" Uncompressed size: %s\n", formatBytes(result.TotalUncompressed))
502
502
-
503
503
-
// Calculate compression ratio
504
504
-
if result.TotalUncompressed > 0 {
505
505
-
ratio := float64(result.TotalUncompressed) / float64(result.TotalSize)
506
506
-
fmt.Printf(" Compression ratio: %.2fx\n", ratio)
507
507
-
}
508
508
-
509
509
-
fmt.Printf(" Average speed: %.1f bundles/sec\n", float64(result.BundleCount)/elapsed.Seconds())
510
510
-
511
511
-
if elapsed.Seconds() > 0 {
512
512
-
compressedThroughput := float64(result.TotalSize) / elapsed.Seconds() / (1000 * 1000)
513
513
-
uncompressedThroughput := float64(result.TotalUncompressed) / elapsed.Seconds() / (1000 * 1000)
514
514
-
fmt.Printf(" Throughput (compressed): %.1f MB/s\n", compressedThroughput)
515
515
-
fmt.Printf(" Throughput (uncompressed): %.1f MB/s\n", uncompressedThroughput)
516
516
-
}
517
517
-
518
518
-
fmt.Printf(" Index file: %s\n", filepath.Join(dir, bundleindex.INDEX_FILE))
519
519
-
520
520
-
if len(result.MissingGaps) > 0 {
521
521
-
fmt.Printf(" ⚠️ Missing gaps: %d bundles\n", len(result.MissingGaps))
522
522
-
}
523
523
-
524
524
-
// Verify chain if requested
525
525
-
if *verbose {
526
526
-
fmt.Printf("\n")
527
527
-
fmt.Printf("Verifying chain integrity...\n")
528
528
-
529
529
-
ctx := context.Background()
530
530
-
verifyResult, err := mgr.VerifyChain(ctx)
531
531
-
if err != nil {
532
532
-
fmt.Printf(" ⚠️ Verification error: %v\n", err)
533
533
-
} else if verifyResult.Valid {
534
534
-
fmt.Printf(" ✓ Chain is valid (%d bundles verified)\n", len(verifyResult.VerifiedBundles))
535
535
-
536
536
-
// Show head hash
537
537
-
index := mgr.GetIndex()
538
538
-
if lastMeta := index.GetLastBundle(); lastMeta != nil {
539
539
-
fmt.Printf(" Chain head: %s...\n", lastMeta.Hash[:16])
540
540
-
}
541
541
-
} else {
542
542
-
fmt.Printf(" ✗ Chain verification failed\n")
543
543
-
fmt.Printf(" Broken at: bundle %06d\n", verifyResult.BrokenAt)
544
544
-
fmt.Printf(" Error: %s\n", verifyResult.Error)
545
545
-
}
546
546
-
}
547
547
-
}
548
548
-
549
549
-
func cmdVerify() {
550
550
-
fs := flag.NewFlagSet("verify", flag.ExitOnError)
551
551
-
bundleNum := fs.Int("bundle", 0, "specific bundle to verify (0 = verify chain)")
552
552
-
verbose := fs.Bool("v", false, "verbose output")
553
553
-
fs.Parse(os.Args[2:])
554
554
-
555
555
-
mgr, dir, err := getManager("")
556
556
-
if err != nil {
557
557
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
558
558
-
os.Exit(1)
559
559
-
}
560
560
-
defer mgr.Close()
561
561
-
562
562
-
fmt.Printf("Working in: %s\n", dir)
563
563
-
564
564
-
ctx := context.Background()
565
565
-
566
566
-
if *bundleNum > 0 {
567
567
-
// Verify specific bundle
568
568
-
fmt.Printf("Verifying bundle %06d...\n", *bundleNum)
569
569
-
570
570
-
result, err := mgr.VerifyBundle(ctx, *bundleNum)
571
571
-
if err != nil {
572
572
-
fmt.Fprintf(os.Stderr, "Verification failed: %v\n", err)
573
573
-
os.Exit(1)
574
574
-
}
575
575
-
576
576
-
if result.Valid {
577
577
-
fmt.Printf("✓ Bundle %06d is valid\n", *bundleNum)
578
578
-
if *verbose {
579
579
-
fmt.Printf(" File exists: %v\n", result.FileExists)
580
580
-
fmt.Printf(" Hash match: %v\n", result.HashMatch)
581
581
-
fmt.Printf(" Hash: %s\n", result.LocalHash[:16]+"...")
582
582
-
}
583
583
-
} else {
584
584
-
fmt.Printf("✗ Bundle %06d is invalid\n", *bundleNum)
585
585
-
if result.Error != nil {
586
586
-
fmt.Printf(" Error: %v\n", result.Error)
587
587
-
}
588
588
-
if !result.FileExists {
589
589
-
fmt.Printf(" File not found\n")
590
590
-
}
591
591
-
if !result.HashMatch && result.FileExists {
592
592
-
fmt.Printf(" Expected hash: %s...\n", result.ExpectedHash[:16])
593
593
-
fmt.Printf(" Actual hash: %s...\n", result.LocalHash[:16])
594
594
-
}
595
595
-
os.Exit(1)
596
596
-
}
597
597
-
} else {
598
598
-
// Verify entire chain
599
599
-
index := mgr.GetIndex()
600
600
-
bundles := index.GetBundles()
601
601
-
602
602
-
if len(bundles) == 0 {
603
603
-
fmt.Println("No bundles to verify")
604
604
-
return
605
605
-
}
606
606
-
607
607
-
fmt.Printf("Verifying chain of %d bundles...\n", len(bundles))
608
608
-
fmt.Println()
609
609
-
610
610
-
verifiedCount := 0
611
611
-
errorCount := 0
612
612
-
lastPercent := -1
613
613
-
614
614
-
for i, meta := range bundles {
615
615
-
bundleNum := meta.BundleNumber
616
616
-
617
617
-
// Show progress
618
618
-
percent := (i * 100) / len(bundles)
619
619
-
if percent != lastPercent || *verbose {
620
620
-
if *verbose {
621
621
-
fmt.Printf(" [%3d%%] Verifying bundle %06d...", percent, bundleNum)
622
622
-
} else if percent%10 == 0 && percent != lastPercent {
623
623
-
fmt.Printf(" [%3d%%] Verified %d/%d bundles...\n", percent, i, len(bundles))
624
624
-
}
625
625
-
lastPercent = percent
626
626
-
}
627
627
-
628
628
-
// Verify file hash
629
629
-
result, err := mgr.VerifyBundle(ctx, bundleNum)
630
630
-
if err != nil {
631
631
-
if *verbose {
632
632
-
fmt.Printf(" ERROR\n")
633
633
-
}
634
634
-
fmt.Printf("\n✗ Failed to verify bundle %06d: %v\n", bundleNum, err)
635
635
-
errorCount++
636
636
-
continue
637
637
-
}
638
638
-
639
639
-
if !result.Valid {
640
640
-
if *verbose {
641
641
-
fmt.Printf(" INVALID\n")
642
642
-
}
643
643
-
fmt.Printf("\n✗ Bundle %06d hash verification failed\n", bundleNum)
644
644
-
if result.Error != nil {
645
645
-
fmt.Printf(" Error: %v\n", result.Error)
646
646
-
}
647
647
-
errorCount++
648
648
-
continue
649
649
-
}
650
650
-
651
651
-
// Verify chain link (prev_bundle_hash)
652
652
-
if i > 0 {
653
653
-
prevMeta := bundles[i-1]
654
654
-
if meta.Parent != prevMeta.Hash {
655
655
-
if *verbose {
656
656
-
fmt.Printf(" CHAIN BROKEN\n")
657
657
-
}
658
658
-
fmt.Printf("\n✗ Chain broken at bundle %06d\n", bundleNum)
659
659
-
fmt.Printf(" Expected parent: %s...\n", prevMeta.Hash[:16])
660
660
-
fmt.Printf(" Actual parent: %s...\n", meta.Parent[:16])
661
661
-
errorCount++
662
662
-
continue
663
663
-
}
664
664
-
}
665
665
-
666
666
-
if *verbose {
667
667
-
fmt.Printf(" ✓\n")
668
668
-
}
669
669
-
verifiedCount++
670
670
-
}
671
671
-
672
672
-
// Final summary
673
673
-
fmt.Println()
674
674
-
if errorCount == 0 {
675
675
-
fmt.Printf("✓ Chain is valid (%d bundles verified)\n", verifiedCount)
676
676
-
fmt.Printf(" First bundle: %06d\n", bundles[0].BundleNumber)
677
677
-
fmt.Printf(" Last bundle: %06d\n", bundles[len(bundles)-1].BundleNumber)
678
678
-
fmt.Printf(" Chain head: %s...\n", bundles[len(bundles)-1].Hash[:16])
679
679
-
} else {
680
680
-
fmt.Printf("✗ Chain verification failed\n")
681
681
-
fmt.Printf(" Verified: %d/%d bundles\n", verifiedCount, len(bundles))
682
682
-
fmt.Printf(" Errors: %d\n", errorCount)
683
683
-
os.Exit(1)
684
684
-
}
685
685
-
}
686
686
-
}
687
687
-
688
688
-
func cmdInfo() {
689
689
-
fs := flag.NewFlagSet("info", flag.ExitOnError)
690
690
-
bundleNum := fs.Int("bundle", 0, "specific bundle info (0 = general info)")
691
691
-
verbose := fs.Bool("v", false, "verbose output")
692
692
-
showBundles := fs.Bool("bundles", false, "show bundle list")
693
693
-
verify := fs.Bool("verify", false, "verify chain integrity")
694
694
-
showTimeline := fs.Bool("timeline", false, "show timeline visualization")
695
695
-
fs.Parse(os.Args[2:])
696
696
-
697
697
-
mgr, dir, err := getManager("")
698
698
-
if err != nil {
699
699
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
700
700
-
os.Exit(1)
701
701
-
}
702
702
-
defer mgr.Close()
703
703
-
704
704
-
if *bundleNum > 0 {
705
705
-
showBundleInfo(mgr, dir, *bundleNum, *verbose)
706
706
-
} else {
707
707
-
showGeneralInfo(mgr, dir, *verbose, *showBundles, *verify, *showTimeline)
708
708
-
}
709
709
-
}
710
710
-
711
711
-
func showBundleInfo(mgr *bundle.Manager, dir string, bundleNum int, verbose bool) {
712
712
-
ctx := context.Background()
713
713
-
b, err := mgr.LoadBundle(ctx, bundleNum)
714
714
-
if err != nil {
715
715
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
716
716
-
os.Exit(1)
717
717
-
}
718
718
-
719
719
-
fmt.Printf("\n")
720
720
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
721
721
-
fmt.Printf(" Bundle %06d\n", b.BundleNumber)
722
722
-
fmt.Printf("═══════════════════════════════════════════════════════════════\n")
723
723
-
fmt.Printf("\n")
724
724
-
725
725
-
// Location
726
726
-
fmt.Printf("📁 Location\n")
727
727
-
fmt.Printf(" Directory: %s\n", dir)
728
728
-
fmt.Printf(" File: %06d.jsonl.zst\n", b.BundleNumber)
729
729
-
fmt.Printf("\n")
730
730
-
731
731
-
// Time Range
732
732
-
duration := b.EndTime.Sub(b.StartTime)
733
733
-
fmt.Printf("📅 Time Range\n")
734
734
-
fmt.Printf(" Start: %s\n", b.StartTime.Format("2006-01-02 15:04:05.000 MST"))
735
735
-
fmt.Printf(" End: %s\n", b.EndTime.Format("2006-01-02 15:04:05.000 MST"))
736
736
-
fmt.Printf(" Duration: %s\n", formatDuration(duration))
737
737
-
fmt.Printf(" Created: %s\n", b.CreatedAt.Format("2006-01-02 15:04:05 MST"))
738
738
-
fmt.Printf("\n")
739
739
-
740
740
-
// Content
741
741
-
fmt.Printf("📊 Content\n")
742
742
-
fmt.Printf(" Operations: %s\n", formatNumber(len(b.Operations)))
743
743
-
fmt.Printf(" Unique DIDs: %s\n", formatNumber(b.DIDCount))
744
744
-
if len(b.Operations) > 0 {
745
745
-
avgOpsPerDID := float64(len(b.Operations)) / float64(b.DIDCount)
746
746
-
fmt.Printf(" Avg ops/DID: %.2f\n", avgOpsPerDID)
747
747
-
}
748
748
-
fmt.Printf("\n")
749
749
-
750
750
-
// Size
751
751
-
fmt.Printf("💾 Size\n")
752
752
-
fmt.Printf(" Compressed: %s\n", formatBytes(b.CompressedSize))
753
753
-
fmt.Printf(" Uncompressed: %s\n", formatBytes(b.UncompressedSize))
754
754
-
fmt.Printf(" Ratio: %.2fx\n", b.CompressionRatio())
755
755
-
fmt.Printf(" Efficiency: %.1f%% savings\n", (1-float64(b.CompressedSize)/float64(b.UncompressedSize))*100)
756
756
-
fmt.Printf("\n")
757
757
-
758
758
-
// Hashes
759
759
-
fmt.Printf("🔐 Cryptographic Hashes\n")
760
760
-
fmt.Printf(" Chain Hash:\n")
761
761
-
fmt.Printf(" %s\n", b.Hash)
762
762
-
fmt.Printf(" Content Hash:\n")
763
763
-
fmt.Printf(" %s\n", b.ContentHash)
764
764
-
fmt.Printf(" Compressed:\n")
765
765
-
fmt.Printf(" %s\n", b.CompressedHash)
766
766
-
if b.Parent != "" {
767
767
-
fmt.Printf(" Parent Chain Hash:\n")
768
768
-
fmt.Printf(" %s\n", b.Parent)
769
769
-
}
770
770
-
fmt.Printf("\n")
771
771
-
772
772
-
// Chain
773
773
-
if b.Parent != "" || b.Cursor != "" {
774
774
-
fmt.Printf("🔗 Chain Information\n")
775
775
-
if b.Cursor != "" {
776
776
-
fmt.Printf(" Cursor: %s\n", b.Cursor)
777
777
-
}
778
778
-
if b.Parent != "" {
779
779
-
fmt.Printf(" Links to: Bundle %06d\n", bundleNum-1)
780
780
-
}
781
781
-
if len(b.BoundaryCIDs) > 0 {
782
782
-
fmt.Printf(" Boundary: %d CIDs at same timestamp\n", len(b.BoundaryCIDs))
783
783
-
}
784
784
-
fmt.Printf("\n")
785
785
-
}
786
786
-
787
787
-
// Verbose: Show sample operations
788
788
-
if verbose && len(b.Operations) > 0 {
789
789
-
fmt.Printf("📝 Sample Operations (first 5)\n")
790
790
-
showCount := 5
791
791
-
if len(b.Operations) < showCount {
792
792
-
showCount = len(b.Operations)
793
793
-
}
794
794
-
for i := 0; i < showCount; i++ {
795
795
-
op := b.Operations[i]
796
796
-
fmt.Printf(" %d. %s\n", i+1, op.DID)
797
797
-
fmt.Printf(" CID: %s\n", op.CID)
798
798
-
fmt.Printf(" Time: %s\n", op.CreatedAt.Format("2006-01-02 15:04:05.000"))
799
799
-
if op.IsNullified() {
800
800
-
fmt.Printf(" ⚠️ Nullified: %s\n", op.GetNullifyingCID())
801
801
-
}
802
802
-
}
803
803
-
fmt.Printf("\n")
804
804
-
}
805
805
-
806
806
-
// Verbose: Show DID statistics
807
807
-
if verbose && len(b.Operations) > 0 {
808
808
-
didOps := make(map[string]int)
809
809
-
for _, op := range b.Operations {
810
810
-
didOps[op.DID]++
811
811
-
}
812
812
-
813
813
-
// Find most active DIDs
814
814
-
type didCount struct {
815
815
-
did string
816
816
-
count int
817
817
-
}
818
818
-
var counts []didCount
819
819
-
for did, count := range didOps {
820
820
-
counts = append(counts, didCount{did, count})
821
821
-
}
822
822
-
sort.Slice(counts, func(i, j int) bool {
823
823
-
return counts[i].count > counts[j].count
824
824
-
})
825
825
-
826
826
-
fmt.Printf("🏆 Most Active DIDs\n")
827
827
-
showCount := 5
828
828
-
if len(counts) < showCount {
829
829
-
showCount = len(counts)
830
830
-
}
831
831
-
for i := 0; i < showCount; i++ {
832
832
-
fmt.Printf(" %d. %s (%d ops)\n", i+1, counts[i].did, counts[i].count)
833
833
-
}
834
834
-
fmt.Printf("\n")
835
835
-
}
836
836
-
}
837
837
-
838
838
-
func cmdExport() {
839
839
-
fs := flag.NewFlagSet("export", flag.ExitOnError)
840
840
-
bundles := fs.String("bundles", "", "bundle number or range (e.g., '42' or '1-100')")
841
841
-
all := fs.Bool("all", false, "export all bundles")
842
842
-
count := fs.Int("count", 0, "limit number of operations (0 = all)")
843
843
-
after := fs.String("after", "", "timestamp to start after (RFC3339)")
844
844
-
fs.Parse(os.Args[2:])
845
845
-
846
846
-
// Validate flags
847
847
-
if !*all && *bundles == "" {
848
848
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle export --bundles <number|range> [options]\n")
849
849
-
fmt.Fprintf(os.Stderr, " or: plcbundle export --all [options]\n")
850
850
-
fmt.Fprintf(os.Stderr, "\nExamples:\n")
851
851
-
fmt.Fprintf(os.Stderr, " plcbundle export --bundles 42\n")
852
852
-
fmt.Fprintf(os.Stderr, " plcbundle export --bundles 1-100\n")
853
853
-
fmt.Fprintf(os.Stderr, " plcbundle export --all\n")
854
854
-
fmt.Fprintf(os.Stderr, " plcbundle export --all --count 50000\n")
855
855
-
fmt.Fprintf(os.Stderr, " plcbundle export --bundles 42 | jq .\n")
856
856
-
os.Exit(1)
857
857
-
}
858
858
-
859
859
-
// Load manager
860
860
-
mgr, _, err := getManager("")
861
861
-
if err != nil {
862
862
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
863
863
-
os.Exit(1)
864
864
-
}
865
865
-
defer mgr.Close()
866
866
-
867
867
-
// Determine bundle range
868
868
-
var start, end int
869
869
-
if *all {
870
870
-
// Export all bundles
871
871
-
index := mgr.GetIndex()
872
872
-
bundles := index.GetBundles()
873
873
-
if len(bundles) == 0 {
874
874
-
fmt.Fprintf(os.Stderr, "No bundles available\n")
875
875
-
os.Exit(1)
876
876
-
}
877
877
-
start = bundles[0].BundleNumber
878
878
-
end = bundles[len(bundles)-1].BundleNumber
879
879
-
880
880
-
fmt.Fprintf(os.Stderr, "Exporting all bundles (%d-%d)\n", start, end)
881
881
-
} else {
882
882
-
// Parse bundle range
883
883
-
start, end, err = parseBundleRange(*bundles)
884
884
-
if err != nil {
885
885
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
886
886
-
os.Exit(1)
887
887
-
}
888
888
-
fmt.Fprintf(os.Stderr, "Exporting bundles %d-%d\n", start, end)
889
889
-
}
890
890
-
891
891
-
// Log to stderr
892
892
-
if *count > 0 {
893
893
-
fmt.Fprintf(os.Stderr, "Limit: %d operations\n", *count)
894
894
-
}
895
895
-
if *after != "" {
896
896
-
fmt.Fprintf(os.Stderr, "After: %s\n", *after)
897
897
-
}
898
898
-
fmt.Fprintf(os.Stderr, "\n")
899
899
-
900
900
-
// Parse after time if provided
901
901
-
var afterTime time.Time
902
902
-
if *after != "" {
903
903
-
afterTime, err = time.Parse(time.RFC3339, *after)
904
904
-
if err != nil {
905
905
-
fmt.Fprintf(os.Stderr, "Invalid after time: %v\n", err)
906
906
-
os.Exit(1)
907
907
-
}
908
908
-
}
909
909
-
910
910
-
ctx := context.Background()
911
911
-
exported := 0
912
912
-
913
913
-
// Export operations from bundles
914
914
-
for bundleNum := start; bundleNum <= end; bundleNum++ {
915
915
-
// Check if we've reached the limit
916
916
-
if *count > 0 && exported >= *count {
917
917
-
break
918
918
-
}
919
919
-
920
920
-
fmt.Fprintf(os.Stderr, "Processing bundle %d...\r", bundleNum)
921
921
-
922
922
-
bundle, err := mgr.LoadBundle(ctx, bundleNum)
923
923
-
if err != nil {
924
924
-
fmt.Fprintf(os.Stderr, "\nWarning: failed to load bundle %d: %v\n", bundleNum, err)
925
925
-
continue
926
926
-
}
927
927
-
928
928
-
// Output operations
929
929
-
for _, op := range bundle.Operations {
930
930
-
// Check after time filter
931
931
-
if !afterTime.IsZero() && op.CreatedAt.Before(afterTime) {
932
932
-
continue
933
933
-
}
934
934
-
935
935
-
// Check count limit
936
936
-
if *count > 0 && exported >= *count {
937
937
-
break
938
938
-
}
939
939
-
940
940
-
// Output operation as JSONL
941
941
-
if len(op.RawJSON) > 0 {
942
942
-
fmt.Println(string(op.RawJSON))
943
943
-
} else {
944
944
-
// Fallback to marshaling
945
945
-
data, _ := json.Marshal(op)
946
946
-
fmt.Println(string(data))
947
947
-
}
948
948
-
949
949
-
exported++
950
950
-
}
951
951
-
}
952
952
-
953
953
-
// Final stats to stderr
954
954
-
fmt.Fprintf(os.Stderr, "\n\n")
955
955
-
fmt.Fprintf(os.Stderr, "✓ Export complete\n")
956
956
-
fmt.Fprintf(os.Stderr, " Exported: %d operations\n", exported)
957
957
-
}
958
958
-
959
959
-
func cmdBackfill() {
960
960
-
fs := flag.NewFlagSet("backfill", flag.ExitOnError)
961
961
-
plcURL := fs.String("plc", "https://plc.directory", "PLC directory URL")
962
962
-
startFrom := fs.Int("start", 1, "bundle number to start from")
963
963
-
endAt := fs.Int("end", 0, "bundle number to end at (0 = until caught up)")
964
964
-
verbose := fs.Bool("verbose", false, "verbose sync logging")
965
965
-
fs.Parse(os.Args[2:])
966
966
-
967
967
-
mgr, dir, err := getManager(*plcURL)
968
968
-
if err != nil {
969
969
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
970
970
-
os.Exit(1)
971
971
-
}
972
972
-
defer mgr.Close()
973
973
-
974
974
-
fmt.Fprintf(os.Stderr, "Starting backfill from: %s\n", dir)
975
975
-
fmt.Fprintf(os.Stderr, "Starting from bundle: %06d\n", *startFrom)
976
976
-
if *endAt > 0 {
977
977
-
fmt.Fprintf(os.Stderr, "Ending at bundle: %06d\n", *endAt)
978
978
-
} else {
979
979
-
fmt.Fprintf(os.Stderr, "Ending: when caught up\n")
980
980
-
}
981
981
-
fmt.Fprintf(os.Stderr, "\n")
982
982
-
983
983
-
ctx := context.Background()
984
984
-
985
985
-
currentBundle := *startFrom
986
986
-
processedCount := 0
987
987
-
fetchedCount := 0
988
988
-
loadedCount := 0
989
989
-
operationCount := 0
990
990
-
991
991
-
for {
992
992
-
// Check if we've reached the end bundle
993
993
-
if *endAt > 0 && currentBundle > *endAt {
994
994
-
break
995
995
-
}
996
996
-
997
997
-
fmt.Fprintf(os.Stderr, "Processing bundle %06d... ", currentBundle)
998
998
-
999
999
-
// Try to load from disk first
1000
1000
-
bundle, err := mgr.LoadBundle(ctx, currentBundle)
1001
1001
-
1002
1002
-
if err != nil {
1003
1003
-
// Bundle doesn't exist, try to fetch it
1004
1004
-
fmt.Fprintf(os.Stderr, "fetching... ")
1005
1005
-
1006
1006
-
bundle, err = mgr.FetchNextBundle(ctx, !*verbose)
1007
1007
-
if err != nil {
1008
1008
-
if isEndOfDataError(err) {
1009
1009
-
fmt.Fprintf(os.Stderr, "\n✓ Caught up! No more complete bundles available.\n")
1010
1010
-
break
1011
1011
-
}
1012
1012
-
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
1013
1013
-
1014
1014
-
// If we can't fetch, we're done
1015
1015
-
break
1016
1016
-
}
1017
1017
-
1018
1018
-
// Save the fetched bundle
1019
1019
-
if err := mgr.SaveBundle(ctx, bundle, !*verbose); err != nil {
1020
1020
-
fmt.Fprintf(os.Stderr, "ERROR saving: %v\n", err)
1021
1021
-
os.Exit(1)
1022
1022
-
}
1023
1023
-
1024
1024
-
fetchedCount++
1025
1025
-
fmt.Fprintf(os.Stderr, "saved... ")
1026
1026
-
} else {
1027
1027
-
loadedCount++
1028
1028
-
}
1029
1029
-
1030
1030
-
// Output operations to stdout (JSONL)
1031
1031
-
for _, op := range bundle.Operations {
1032
1032
-
if len(op.RawJSON) > 0 {
1033
1033
-
fmt.Println(string(op.RawJSON))
1034
1034
-
}
1035
1035
-
}
1036
1036
-
1037
1037
-
operationCount += len(bundle.Operations)
1038
1038
-
processedCount++
1039
1039
-
1040
1040
-
fmt.Fprintf(os.Stderr, "✓ (%d ops, %d DIDs)\n", len(bundle.Operations), bundle.DIDCount)
1041
1041
-
1042
1042
-
currentBundle++
1043
1043
-
1044
1044
-
// Show progress summary every 100 bundles
1045
1045
-
if processedCount%100 == 0 {
1046
1046
-
fmt.Fprintf(os.Stderr, "\n--- Progress: %d bundles processed (%d fetched, %d loaded) ---\n",
1047
1047
-
processedCount, fetchedCount, loadedCount)
1048
1048
-
fmt.Fprintf(os.Stderr, " Total operations: %d\n\n", operationCount)
1049
1049
-
}
1050
1050
-
}
1051
1051
-
1052
1052
-
// Final summary
1053
1053
-
fmt.Fprintf(os.Stderr, "\n")
1054
1054
-
fmt.Fprintf(os.Stderr, "✓ Backfill complete\n")
1055
1055
-
fmt.Fprintf(os.Stderr, " Bundles processed: %d\n", processedCount)
1056
1056
-
fmt.Fprintf(os.Stderr, " Newly fetched: %d\n", fetchedCount)
1057
1057
-
fmt.Fprintf(os.Stderr, " Loaded from disk: %d\n", loadedCount)
1058
1058
-
fmt.Fprintf(os.Stderr, " Total operations: %d\n", operationCount)
1059
1059
-
fmt.Fprintf(os.Stderr, " Range: %06d - %06d\n", *startFrom, currentBundle-1)
1060
1060
-
}
1061
1061
-
1062
1062
-
func cmdMempool() {
1063
1063
-
fs := flag.NewFlagSet("mempool", flag.ExitOnError)
1064
1064
-
clear := fs.Bool("clear", false, "clear the mempool")
1065
1065
-
export := fs.Bool("export", false, "export mempool operations as JSONL to stdout")
1066
1066
-
refresh := fs.Bool("refresh", false, "reload mempool from disk")
1067
1067
-
validate := fs.Bool("validate", false, "validate chronological order")
1068
1068
-
verbose := fs.Bool("v", false, "verbose output")
1069
1069
-
fs.Parse(os.Args[2:])
1070
1070
-
1071
1071
-
mgr, dir, err := getManager("")
1072
1072
-
if err != nil {
1073
1073
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
1074
1074
-
os.Exit(1)
1075
1075
-
}
1076
1076
-
defer mgr.Close()
1077
1077
-
1078
1078
-
fmt.Printf("Working in: %s\n", dir)
1079
1079
-
fmt.Println()
1080
1080
-
1081
1081
-
// Handle validate
1082
1082
-
if *validate {
1083
1083
-
fmt.Printf("Validating mempool chronological order...\n")
1084
1084
-
if err := mgr.ValidateMempool(); err != nil {
1085
1085
-
fmt.Fprintf(os.Stderr, "✗ Validation failed: %v\n", err)
1086
1086
-
os.Exit(1)
1087
1087
-
}
1088
1088
-
fmt.Printf("✓ Mempool validation passed\n")
1089
1089
-
return
1090
1090
-
}
1091
1091
-
1092
1092
-
// Handle refresh
1093
1093
-
if *refresh {
1094
1094
-
fmt.Printf("Refreshing mempool from disk...\n")
1095
1095
-
if err := mgr.RefreshMempool(); err != nil {
1096
1096
-
fmt.Fprintf(os.Stderr, "Error refreshing mempool: %v\n", err)
1097
1097
-
os.Exit(1)
1098
1098
-
}
1099
1099
-
1100
1100
-
// Validate after refresh
1101
1101
-
if err := mgr.ValidateMempool(); err != nil {
1102
1102
-
fmt.Fprintf(os.Stderr, "⚠ Warning: mempool validation failed after refresh: %v\n", err)
1103
1103
-
} else {
1104
1104
-
fmt.Printf("✓ Mempool refreshed and validated\n\n")
1105
1105
-
}
1106
1106
-
}
1107
1107
-
1108
1108
-
// Handle clear
1109
1109
-
if *clear {
1110
1110
-
stats := mgr.GetMempoolStats()
1111
1111
-
count := stats["count"].(int)
1112
1112
-
1113
1113
-
if count == 0 {
1114
1114
-
fmt.Println("Mempool is already empty")
1115
1115
-
return
1116
1116
-
}
1117
1117
-
1118
1118
-
fmt.Printf("⚠ This will clear %d operations from the mempool.\n", count)
1119
1119
-
fmt.Printf("Are you sure? [y/N]: ")
1120
1120
-
var response string
1121
1121
-
fmt.Scanln(&response)
1122
1122
-
if strings.ToLower(strings.TrimSpace(response)) != "y" {
1123
1123
-
fmt.Println("Cancelled")
1124
1124
-
return
1125
1125
-
}
1126
1126
-
1127
1127
-
if err := mgr.ClearMempool(); err != nil {
1128
1128
-
fmt.Fprintf(os.Stderr, "Error clearing mempool: %v\n", err)
1129
1129
-
os.Exit(1)
1130
1130
-
}
1131
1131
-
1132
1132
-
fmt.Printf("✓ Mempool cleared (%d operations removed)\n", count)
1133
1133
-
return
1134
1134
-
}
85
85
+
Examples:
86
86
+
plcbundle fetch
87
87
+
plcbundle clone https://plc.example.com
88
88
+
plcbundle info --bundles
89
89
+
plcbundle serve --sync --websocket
90
90
+
plcbundle detector run invalid_handle --bundles 1-100
1135
91
1136
1136
-
// Handle export
1137
1137
-
if *export {
1138
1138
-
ops, err := mgr.GetMempoolOperations()
1139
1139
-
if err != nil {
1140
1140
-
fmt.Fprintf(os.Stderr, "Error getting mempool operations: %v\n", err)
1141
1141
-
os.Exit(1)
1142
1142
-
}
1143
1143
-
1144
1144
-
if len(ops) == 0 {
1145
1145
-
fmt.Fprintf(os.Stderr, "Mempool is empty\n")
1146
1146
-
return
1147
1147
-
}
1148
1148
-
1149
1149
-
// Output as JSONL to stdout
1150
1150
-
for _, op := range ops {
1151
1151
-
if len(op.RawJSON) > 0 {
1152
1152
-
fmt.Println(string(op.RawJSON))
1153
1153
-
}
1154
1154
-
}
1155
1155
-
1156
1156
-
fmt.Fprintf(os.Stderr, "Exported %d operations from mempool\n", len(ops))
1157
1157
-
return
1158
1158
-
}
1159
1159
-
1160
1160
-
// Default: Show mempool stats
1161
1161
-
stats := mgr.GetMempoolStats()
1162
1162
-
count := stats["count"].(int)
1163
1163
-
canCreate := stats["can_create_bundle"].(bool)
1164
1164
-
targetBundle := stats["target_bundle"].(int)
1165
1165
-
minTimestamp := stats["min_timestamp"].(time.Time)
1166
1166
-
validated := stats["validated"].(bool)
1167
1167
-
1168
1168
-
fmt.Printf("Mempool Status:\n")
1169
1169
-
fmt.Printf(" Target bundle: %06d\n", targetBundle)
1170
1170
-
fmt.Printf(" Operations: %d\n", count)
1171
1171
-
fmt.Printf(" Can create bundle: %v (need %d)\n", canCreate, types.BUNDLE_SIZE)
1172
1172
-
fmt.Printf(" Min timestamp: %s\n", minTimestamp.Format("2006-01-02 15:04:05"))
1173
1173
-
1174
1174
-
validationIcon := "✓"
1175
1175
-
if !validated {
1176
1176
-
validationIcon = "⚠"
1177
1177
-
}
1178
1178
-
fmt.Printf(" Validated: %s %v\n", validationIcon, validated)
1179
1179
-
1180
1180
-
if count > 0 {
1181
1181
-
if sizeBytes, ok := stats["size_bytes"].(int); ok {
1182
1182
-
fmt.Printf(" Size: %.2f KB\n", float64(sizeBytes)/1024)
1183
1183
-
}
1184
1184
-
1185
1185
-
if firstTime, ok := stats["first_time"].(time.Time); ok {
1186
1186
-
fmt.Printf(" First operation: %s\n", firstTime.Format("2006-01-02 15:04:05"))
1187
1187
-
}
1188
1188
-
1189
1189
-
if lastTime, ok := stats["last_time"].(time.Time); ok {
1190
1190
-
fmt.Printf(" Last operation: %s\n", lastTime.Format("2006-01-02 15:04:05"))
1191
1191
-
}
1192
1192
-
1193
1193
-
progress := float64(count) / float64(types.BUNDLE_SIZE) * 100
1194
1194
-
fmt.Printf(" Progress: %.1f%% (%d/%d)\n", progress, count, types.BUNDLE_SIZE)
1195
1195
-
1196
1196
-
// Show progress bar
1197
1197
-
barWidth := 40
1198
1198
-
filled := int(float64(barWidth) * float64(count) / float64(types.BUNDLE_SIZE))
1199
1199
-
if filled > barWidth {
1200
1200
-
filled = barWidth
1201
1201
-
}
1202
1202
-
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
1203
1203
-
fmt.Printf(" [%s]\n", bar)
1204
1204
-
} else {
1205
1205
-
fmt.Printf(" (empty)\n")
1206
1206
-
}
1207
1207
-
1208
1208
-
// Verbose: Show sample operations
1209
1209
-
if *verbose && count > 0 {
1210
1210
-
fmt.Println()
1211
1211
-
fmt.Printf("Sample operations (showing up to 10):\n")
1212
1212
-
1213
1213
-
ops, err := mgr.GetMempoolOperations()
1214
1214
-
if err != nil {
1215
1215
-
fmt.Fprintf(os.Stderr, "Error getting operations: %v\n", err)
1216
1216
-
os.Exit(1)
1217
1217
-
}
1218
1218
-
1219
1219
-
showCount := 10
1220
1220
-
if len(ops) < showCount {
1221
1221
-
showCount = len(ops)
1222
1222
-
}
1223
1223
-
1224
1224
-
for i := 0; i < showCount; i++ {
1225
1225
-
op := ops[i]
1226
1226
-
fmt.Printf(" %d. DID: %s\n", i+1, op.DID)
1227
1227
-
fmt.Printf(" CID: %s\n", op.CID)
1228
1228
-
fmt.Printf(" Created: %s\n", op.CreatedAt.Format("2006-01-02 15:04:05.000"))
1229
1229
-
}
1230
1230
-
1231
1231
-
if len(ops) > showCount {
1232
1232
-
fmt.Printf(" ... and %d more\n", len(ops)-showCount)
1233
1233
-
}
1234
1234
-
}
1235
1235
-
1236
1236
-
fmt.Println()
1237
1237
-
1238
1238
-
// Show mempool file
1239
1239
-
mempoolFilename := fmt.Sprintf("plc_mempool_%06d.jsonl", targetBundle)
1240
1240
-
fmt.Printf("File: %s\n", filepath.Join(dir, mempoolFilename))
1241
1241
-
}
1242
1242
-
1243
1243
-
func cmdServe() {
1244
1244
-
fs := flag.NewFlagSet("serve", flag.ExitOnError)
1245
1245
-
port := fs.String("port", "8080", "HTTP server port")
1246
1246
-
host := fs.String("host", "127.0.0.1", "HTTP server host")
1247
1247
-
sync := fs.Bool("sync", false, "enable sync mode (auto-sync from PLC)")
1248
1248
-
plcURL := fs.String("plc", "https://plc.directory", "PLC directory URL (for sync mode)")
1249
1249
-
syncIntervalFlag := fs.Duration("sync-interval", 1*time.Minute, "sync interval for sync mode")
1250
1250
-
enableWebSocket := fs.Bool("websocket", false, "enable WebSocket endpoint for streaming records")
1251
1251
-
workers := fs.Int("workers", 4, "number of workers for auto-rebuild (0 = CPU count)")
1252
1252
-
verbose := fs.Bool("verbose", false, "verbose sync logging")
1253
1253
-
enableResolver := fs.Bool("resolver", false, "enable DID resolution endpoints (/<did>)")
1254
1254
-
fs.Parse(os.Args[2:])
1255
1255
-
1256
1256
-
serverStartTime = time.Now()
1257
1257
-
syncInterval = *syncIntervalFlag
1258
1258
-
verboseMode = *verbose
1259
1259
-
resolverEnabled = *enableResolver
1260
1260
-
1261
1261
-
// Auto-detect CPU count
1262
1262
-
if *workers == 0 {
1263
1263
-
*workers = runtime.NumCPU()
1264
1264
-
}
1265
1265
-
1266
1266
-
// Create manager with PLC client if sync mode is enabled
1267
1267
-
var plcURLForManager string
1268
1268
-
if *sync {
1269
1269
-
plcURLForManager = *plcURL
1270
1270
-
}
1271
1271
-
1272
1272
-
dir, err := os.Getwd()
1273
1273
-
if err != nil {
1274
1274
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
1275
1275
-
os.Exit(1)
1276
1276
-
}
1277
1277
-
1278
1278
-
if err := os.MkdirAll(dir, 0755); err != nil {
1279
1279
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
1280
1280
-
os.Exit(1)
1281
1281
-
}
1282
1282
-
1283
1283
-
// Create manager config with progress tracking
1284
1284
-
config := bundle.DefaultConfig(dir)
1285
1285
-
config.RebuildWorkers = *workers
1286
1286
-
config.RebuildProgress = func(current, total int) {
1287
1287
-
if current%100 == 0 || current == total {
1288
1288
-
fmt.Printf(" Rebuild progress: %d/%d bundles (%.1f%%) \r",
1289
1289
-
current, total, float64(current)/float64(total)*100)
1290
1290
-
if current == total {
1291
1291
-
fmt.Println()
1292
1292
-
}
1293
1293
-
}
1294
1294
-
}
1295
1295
-
1296
1296
-
var client *plcclient.Client
1297
1297
-
if plcURLForManager != "" {
1298
1298
-
client = plcclient.NewClient(plcURLForManager)
1299
1299
-
}
1300
1300
-
1301
1301
-
fmt.Printf("Starting plcbundle HTTP server...\n")
1302
1302
-
fmt.Printf(" Directory: %s\n", dir)
1303
1303
-
1304
1304
-
// NewManager handles auto-rebuild of bundle index
1305
1305
-
mgr, err := bundle.NewManager(config, client)
1306
1306
-
if err != nil {
1307
1307
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
1308
1308
-
os.Exit(1)
1309
1309
-
}
1310
1310
-
1311
1311
-
if *enableResolver {
1312
1312
-
index := mgr.GetIndex()
1313
1313
-
bundleCount := index.Count()
1314
1314
-
didStats := mgr.GetDIDIndexStats()
1315
1315
-
1316
1316
-
if bundleCount > 0 {
1317
1317
-
needsBuild := false
1318
1318
-
reason := ""
1319
1319
-
1320
1320
-
if !didStats["exists"].(bool) {
1321
1321
-
needsBuild = true
1322
1322
-
reason = "index does not exist"
1323
1323
-
} else {
1324
1324
-
// Check version
1325
1325
-
didIndex := mgr.GetDIDIndex()
1326
1326
-
if didIndex != nil {
1327
1327
-
config := didIndex.GetConfig()
1328
1328
-
if config.Version != didindex.DIDINDEX_VERSION {
1329
1329
-
needsBuild = true
1330
1330
-
reason = fmt.Sprintf("index version outdated (v%d, need v%d)",
1331
1331
-
config.Version, didindex.DIDINDEX_VERSION)
1332
1332
-
} else {
1333
1333
-
// Check if index is behind bundles
1334
1334
-
lastBundle := index.GetLastBundle()
1335
1335
-
if lastBundle != nil && config.LastBundle < lastBundle.BundleNumber {
1336
1336
-
needsBuild = true
1337
1337
-
reason = fmt.Sprintf("index is behind (bundle %d, need %d)",
1338
1338
-
config.LastBundle, lastBundle.BundleNumber)
1339
1339
-
}
1340
1340
-
}
1341
1341
-
}
1342
1342
-
}
1343
1343
-
1344
1344
-
if needsBuild {
1345
1345
-
fmt.Printf(" DID Index: BUILDING (%s)\n", reason)
1346
1346
-
fmt.Printf(" This may take several minutes...\n\n")
1347
1347
-
1348
1348
-
buildStart := time.Now()
1349
1349
-
ctx := context.Background()
1350
1350
-
1351
1351
-
progress := NewProgressBar(bundleCount)
1352
1352
-
err := mgr.BuildDIDIndex(ctx, func(current, total int) {
1353
1353
-
progress.Set(current)
1354
1354
-
})
1355
1355
-
progress.Finish()
1356
1356
-
1357
1357
-
if err != nil {
1358
1358
-
fmt.Fprintf(os.Stderr, "\n⚠️ Warning: Failed to build DID index: %v\n", err)
1359
1359
-
fmt.Fprintf(os.Stderr, " Resolver will use slower fallback mode\n\n")
1360
1360
-
} else {
1361
1361
-
buildTime := time.Since(buildStart)
1362
1362
-
updatedStats := mgr.GetDIDIndexStats()
1363
1363
-
fmt.Printf("\n✓ DID index built in %s\n", buildTime.Round(time.Millisecond))
1364
1364
-
fmt.Printf(" Total DIDs: %s\n\n", formatNumber(int(updatedStats["total_dids"].(int64))))
1365
1365
-
}
1366
1366
-
} else {
1367
1367
-
fmt.Printf(" DID Index: ready (%s DIDs)\n",
1368
1368
-
formatNumber(int(didStats["total_dids"].(int64))))
1369
1369
-
}
1370
1370
-
}
1371
1371
-
1372
1372
-
// ✨ NEW: Verify index consistency on startup
1373
1373
-
if didStats["exists"].(bool) {
1374
1374
-
fmt.Printf(" Verifying index consistency...\n")
1375
1375
-
1376
1376
-
ctx := context.Background()
1377
1377
-
if err := mgr.GetDIDIndex().VerifyAndRepairIndex(ctx, mgr); err != nil {
1378
1378
-
fmt.Fprintf(os.Stderr, "⚠️ Warning: Index verification/repair failed: %v\n", err)
1379
1379
-
fmt.Fprintf(os.Stderr, " Recommend running: plcbundle index build --force\n\n")
1380
1380
-
} else {
1381
1381
-
fmt.Printf(" ✓ Index verified\n")
1382
1382
-
}
1383
1383
-
}
1384
1384
-
}
1385
1385
-
1386
1386
-
addr := fmt.Sprintf("%s:%s", *host, *port)
1387
1387
-
1388
1388
-
fmt.Printf(" Listening: http://%s\n", addr)
1389
1389
-
1390
1390
-
if *sync {
1391
1391
-
fmt.Printf(" Sync mode: ENABLED\n")
1392
1392
-
fmt.Printf(" PLC URL: %s\n", *plcURL)
1393
1393
-
fmt.Printf(" Sync interval: %s\n", syncInterval)
1394
1394
-
} else {
1395
1395
-
fmt.Printf(" Sync mode: disabled\n")
1396
1396
-
}
1397
1397
-
1398
1398
-
if *enableWebSocket {
1399
1399
-
wsScheme := "ws"
1400
1400
-
fmt.Printf(" WebSocket: ENABLED (%s://%s/ws)\n", wsScheme, addr)
1401
1401
-
} else {
1402
1402
-
fmt.Printf(" WebSocket: disabled (use --websocket to enable)\n")
1403
1403
-
}
1404
1404
-
1405
1405
-
if *enableResolver {
1406
1406
-
fmt.Printf(" Resolver: ENABLED (/<did> endpoints)\n")
1407
1407
-
} else {
1408
1408
-
fmt.Printf(" Resolver: disabled (use --resolver to enable)\n")
1409
1409
-
}
1410
1410
-
1411
1411
-
bundleCount := mgr.GetIndex().Count()
1412
1412
-
if bundleCount > 0 {
1413
1413
-
fmt.Printf(" Bundles available: %d\n", bundleCount)
1414
1414
-
} else {
1415
1415
-
fmt.Printf(" Bundles available: 0\n")
1416
1416
-
}
1417
1417
-
1418
1418
-
fmt.Printf("\nPress Ctrl+C to stop\n\n")
1419
1419
-
1420
1420
-
ctx, cancel := context.WithCancel(context.Background())
1421
1421
-
1422
1422
-
// ✨ NEW: Graceful shutdown handler
1423
1423
-
sigChan := make(chan os.Signal, 1)
1424
1424
-
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
1425
1425
-
1426
1426
-
go func() {
1427
1427
-
<-sigChan
1428
1428
-
fmt.Fprintf(os.Stderr, "\n\n⚠️ Shutdown signal received...\n")
1429
1429
-
fmt.Fprintf(os.Stderr, " Saving mempool...\n")
1430
1430
-
1431
1431
-
if err := mgr.SaveMempool(); err != nil {
1432
1432
-
fmt.Fprintf(os.Stderr, " ✗ Failed to save mempool: %v\n", err)
1433
1433
-
} else {
1434
1434
-
fmt.Fprintf(os.Stderr, " ✓ Mempool saved\n")
1435
1435
-
}
1436
1436
-
1437
1437
-
fmt.Fprintf(os.Stderr, " Closing DID index...\n")
1438
1438
-
if err := mgr.GetDIDIndex().Close(); err != nil {
1439
1439
-
fmt.Fprintf(os.Stderr, " ✗ Failed to close index: %v\n", err)
1440
1440
-
} else {
1441
1441
-
fmt.Fprintf(os.Stderr, " ✓ Index closed\n")
1442
1442
-
}
1443
1443
-
1444
1444
-
fmt.Fprintf(os.Stderr, " ✓ Shutdown complete\n")
1445
1445
-
1446
1446
-
cancel()
1447
1447
-
os.Exit(0)
1448
1448
-
}()
1449
1449
-
1450
1450
-
if *sync {
1451
1451
-
go runSync(ctx, mgr, syncInterval, *verbose, *enableResolver)
1452
1452
-
}
1453
1453
-
1454
1454
-
handler := newServerHandler(mgr, *sync, *enableWebSocket, *enableResolver)
1455
1455
-
server := &http.Server{
1456
1456
-
Addr: addr,
1457
1457
-
Handler: handler,
1458
1458
-
}
1459
1459
-
1460
1460
-
if err := server.ListenAndServe(); err != nil {
1461
1461
-
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
1462
1462
-
mgr.SaveMempool()
1463
1463
-
mgr.Close()
1464
1464
-
os.Exit(1)
1465
1465
-
}
1466
1466
-
}
1467
1467
-
1468
1468
-
func cmdCompare() {
1469
1469
-
fs := flag.NewFlagSet("compare", flag.ExitOnError)
1470
1470
-
verbose := fs.Bool("v", false, "verbose output (show all differences)")
1471
1471
-
fetchMissing := fs.Bool("fetch-missing", false, "fetch missing bundles from target")
1472
1472
-
fs.Parse(os.Args[2:])
1473
1473
-
1474
1474
-
if fs.NArg() < 1 {
1475
1475
-
fmt.Fprintf(os.Stderr, "Usage: plcbundle compare <target> [options]\n")
1476
1476
-
fmt.Fprintf(os.Stderr, " target: URL or path to remote plcbundle server/index\n")
1477
1477
-
fmt.Fprintf(os.Stderr, "\nExamples:\n")
1478
1478
-
fmt.Fprintf(os.Stderr, " plcbundle compare https://plc.example.com\n")
1479
1479
-
fmt.Fprintf(os.Stderr, " plcbundle compare https://plc.example.com/index.json\n")
1480
1480
-
fmt.Fprintf(os.Stderr, " plcbundle compare /path/to/plc_bundles.json\n")
1481
1481
-
fmt.Fprintf(os.Stderr, " plcbundle compare https://plc.example.com --fetch-missing\n")
1482
1482
-
fmt.Fprintf(os.Stderr, "\nOptions:\n")
1483
1483
-
fs.PrintDefaults()
1484
1484
-
os.Exit(1)
1485
1485
-
}
1486
1486
-
1487
1487
-
target := fs.Arg(0)
1488
1488
-
1489
1489
-
mgr, dir, err := getManager("")
1490
1490
-
if err != nil {
1491
1491
-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
1492
1492
-
os.Exit(1)
1493
1493
-
}
1494
1494
-
defer mgr.Close()
1495
1495
-
1496
1496
-
fmt.Printf("Comparing: %s\n", dir)
1497
1497
-
fmt.Printf(" Against: %s\n\n", target)
1498
1498
-
1499
1499
-
// Load local index
1500
1500
-
localIndex := mgr.GetIndex()
1501
1501
-
1502
1502
-
// Load target index
1503
1503
-
fmt.Printf("Loading target index...\n")
1504
1504
-
targetIndex, err := loadTargetIndex(target)
1505
1505
-
if err != nil {
1506
1506
-
fmt.Fprintf(os.Stderr, "Error loading target index: %v\n", err)
1507
1507
-
os.Exit(1)
1508
1508
-
}
1509
1509
-
1510
1510
-
// Perform comparison
1511
1511
-
comparison := compareIndexes(localIndex, targetIndex)
1512
1512
-
1513
1513
-
// Display results
1514
1514
-
displayComparison(comparison, *verbose)
1515
1515
-
1516
1516
-
// Fetch missing bundles if requested
1517
1517
-
if *fetchMissing && len(comparison.MissingBundles) > 0 {
1518
1518
-
fmt.Printf("\n")
1519
1519
-
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
1520
1520
-
fmt.Fprintf(os.Stderr, "Error: --fetch-missing only works with remote URLs\n")
1521
1521
-
os.Exit(1)
1522
1522
-
}
1523
1523
-
1524
1524
-
baseURL := strings.TrimSuffix(target, "/index.json")
1525
1525
-
baseURL = strings.TrimSuffix(baseURL, "/plc_bundles.json")
1526
1526
-
1527
1527
-
fmt.Printf("Fetching %d missing bundles...\n\n", len(comparison.MissingBundles))
1528
1528
-
fetchMissingBundles(mgr, baseURL, comparison.MissingBundles)
1529
1529
-
}
1530
1530
-
1531
1531
-
// Exit with error if there are differences
1532
1532
-
if comparison.HasDifferences() {
1533
1533
-
os.Exit(1)
1534
1534
-
}
92
92
+
`, commands.GetVersion())
1535
93
}
-154
cmd/plcbundle/progress.go
···
1
1
-
package main
2
2
-
3
3
-
import (
4
4
-
"fmt"
5
5
-
"os"
6
6
-
"strings"
7
7
-
"sync"
8
8
-
"time"
9
9
-
)
10
10
-
11
11
-
// ProgressBar shows progress of an operation
12
12
-
type ProgressBar struct {
13
13
-
total int
14
14
-
current int
15
15
-
totalBytes int64
16
16
-
currentBytes int64
17
17
-
startTime time.Time
18
18
-
mu sync.Mutex
19
19
-
width int
20
20
-
lastPrint time.Time
21
21
-
showBytes bool
22
22
-
}
23
23
-
24
24
-
// NewProgressBar creates a new progress bar
25
25
-
func NewProgressBar(total int) *ProgressBar {
26
26
-
return &ProgressBar{
27
27
-
total: total,
28
28
-
current: 0,
29
29
-
totalBytes: 0,
30
30
-
currentBytes: 0,
31
31
-
startTime: time.Now(),
32
32
-
width: 40,
33
33
-
lastPrint: time.Now(),
34
34
-
showBytes: false,
35
35
-
}
36
36
-
}
37
37
-
38
38
-
// NewProgressBarWithBytes creates a new progress bar that tracks bytes
39
39
-
func NewProgressBarWithBytes(total int, totalBytes int64) *ProgressBar {
40
40
-
return &ProgressBar{
41
41
-
total: total,
42
42
-
current: 0,
43
43
-
totalBytes: totalBytes,
44
44
-
currentBytes: 0,
45
45
-
startTime: time.Now(),
46
46
-
width: 40,
47
47
-
lastPrint: time.Now(),
48
48
-
showBytes: true,
49
49
-
}
50
50
-
}
51
51
-
52
52
-
// Increment increases the progress by 1
53
53
-
func (pb *ProgressBar) Increment() {
54
54
-
pb.mu.Lock()
55
55
-
defer pb.mu.Unlock()
56
56
-
pb.current++
57
57
-
pb.print()
58
58
-
}
59
59
-
60
60
-
// Set sets the current progress
61
61
-
func (pb *ProgressBar) Set(current int) {
62
62
-
pb.mu.Lock()
63
63
-
defer pb.mu.Unlock()
64
64
-
pb.current = current
65
65
-
pb.print()
66
66
-
}
67
67
-
68
68
-
// SetWithBytes sets the current progress and bytes processed
69
69
-
func (pb *ProgressBar) SetWithBytes(current int, bytesProcessed int64) {
70
70
-
pb.mu.Lock()
71
71
-
defer pb.mu.Unlock()
72
72
-
pb.current = current
73
73
-
pb.currentBytes = bytesProcessed
74
74
-
pb.print()
75
75
-
}
76
76
-
77
77
-
// Finish completes the progress bar
78
78
-
func (pb *ProgressBar) Finish() {
79
79
-
pb.mu.Lock()
80
80
-
defer pb.mu.Unlock()
81
81
-
pb.current = pb.total
82
82
-
pb.currentBytes = pb.totalBytes
83
83
-
pb.print()
84
84
-
fmt.Fprintf(os.Stderr, "\n")
85
85
-
}
86
86
-
87
87
-
// print renders the progress bar (must be called with lock held)
88
88
-
func (pb *ProgressBar) print() {
89
89
-
// Rate limit updates (max 10 per second)
90
90
-
if time.Since(pb.lastPrint) < 100*time.Millisecond && pb.current < pb.total {
91
91
-
return
92
92
-
}
93
93
-
pb.lastPrint = time.Now()
94
94
-
95
95
-
// Calculate percentage
96
96
-
percent := float64(pb.current) / float64(pb.total) * 100
97
97
-
if pb.total == 0 {
98
98
-
percent = 0
99
99
-
}
100
100
-
101
101
-
// Calculate bar
102
102
-
filled := int(float64(pb.width) * float64(pb.current) / float64(pb.total))
103
103
-
if filled > pb.width {
104
104
-
filled = pb.width
105
105
-
}
106
106
-
bar := strings.Repeat("█", filled) + strings.Repeat("░", pb.width-filled)
107
107
-
108
108
-
// Calculate speed and ETA
109
109
-
elapsed := time.Since(pb.startTime)
110
110
-
speed := float64(pb.current) / elapsed.Seconds()
111
111
-
remaining := pb.total - pb.current
112
112
-
var eta time.Duration
113
113
-
if speed > 0 {
114
114
-
eta = time.Duration(float64(remaining)/speed) * time.Second
115
115
-
}
116
116
-
117
117
-
// Show MB/s if bytes are being tracked (changed condition)
118
118
-
if pb.showBytes && pb.currentBytes > 0 {
119
119
-
// Calculate MB/s (using decimal units: 1 MB = 1,000,000 bytes)
120
120
-
mbProcessed := float64(pb.currentBytes) / (1000 * 1000)
121
121
-
mbPerSec := mbProcessed / elapsed.Seconds()
122
122
-
123
123
-
fmt.Fprintf(os.Stderr, "\r [%s] %6.2f%% | %d/%d bundles | %.1f/s | %.1f MB/s | ETA: %s ",
124
124
-
bar,
125
125
-
percent,
126
126
-
pb.current,
127
127
-
pb.total,
128
128
-
speed,
129
129
-
mbPerSec,
130
130
-
formatETA(eta))
131
131
-
} else {
132
132
-
fmt.Fprintf(os.Stderr, "\r [%s] %6.2f%% | %d/%d bundles | %.1f/s | ETA: %s ",
133
133
-
bar,
134
134
-
percent,
135
135
-
pb.current,
136
136
-
pb.total,
137
137
-
speed,
138
138
-
formatETA(eta))
139
139
-
}
140
140
-
}
141
141
-
142
142
-
// formatETA formats the ETA duration
143
143
-
func formatETA(d time.Duration) string {
144
144
-
if d == 0 {
145
145
-
return "calculating..."
146
146
-
}
147
147
-
if d < time.Minute {
148
148
-
return fmt.Sprintf("%ds", int(d.Seconds()))
149
149
-
}
150
150
-
if d < time.Hour {
151
151
-
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
152
152
-
}
153
153
-
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
154
154
-
}
-1165
cmd/plcbundle/server.go
···
1
1
-
package main
2
2
-
3
3
-
import (
4
4
-
"bufio"
5
5
-
"context"
6
6
-
"fmt"
7
7
-
"io"
8
8
-
"net/http"
9
9
-
"os"
10
10
-
"runtime"
11
11
-
"strconv"
12
12
-
"strings"
13
13
-
"time"
14
14
-
15
15
-
"github.com/goccy/go-json"
16
16
-
"github.com/gorilla/websocket"
17
17
-
18
18
-
"tangled.org/atscan.net/plcbundle/internal/bundle"
19
19
-
"tangled.org/atscan.net/plcbundle/internal/types"
20
20
-
"tangled.org/atscan.net/plcbundle/plcclient"
21
21
-
)
22
22
-
23
23
-
var upgrader = websocket.Upgrader{
24
24
-
ReadBufferSize: 1024,
25
25
-
WriteBufferSize: 1024,
26
26
-
CheckOrigin: func(r *http.Request) bool {
27
27
-
return true
28
28
-
},
29
29
-
}
30
30
-
31
31
-
var serverStartTime time.Time
32
32
-
var syncInterval time.Duration
33
33
-
var verboseMode bool
34
34
-
var resolverEnabled bool
35
35
-
36
36
-
// newServerHandler creates HTTP handler with all routes
37
37
-
func newServerHandler(mgr *bundle.Manager, syncMode bool, wsEnabled bool, resolverEnabled bool) http.Handler {
38
38
-
mux := http.NewServeMux()
39
39
-
40
40
-
// Specific routes first (highest priority)
41
41
-
mux.HandleFunc("GET /index.json", handleIndexJSONNative(mgr))
42
42
-
mux.HandleFunc("GET /bundle/{number}", handleBundleNative(mgr))
43
43
-
mux.HandleFunc("GET /data/{number}", handleBundleDataNative(mgr))
44
44
-
mux.HandleFunc("GET /jsonl/{number}", handleBundleJSONLNative(mgr))
45
45
-
mux.HandleFunc("GET /status", handleStatusNative(mgr, syncMode, wsEnabled))
46
46
-
mux.HandleFunc("GET /debug/memory", handleDebugMemoryNative(mgr))
47
47
-
48
48
-
// WebSocket endpoint
49
49
-
if wsEnabled {
50
50
-
mux.HandleFunc("GET /ws", handleWebSocketNative(mgr))
51
51
-
}
52
52
-
53
53
-
// Sync mode endpoints
54
54
-
if syncMode {
55
55
-
mux.HandleFunc("GET /mempool", handleMempoolNative(mgr))
56
56
-
}
57
57
-
58
58
-
// Combined root and DID resolver handler
59
59
-
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
60
60
-
path := r.URL.Path
61
61
-
62
62
-
// Handle exact root
63
63
-
if path == "/" {
64
64
-
handleRootNative(mgr, syncMode, wsEnabled, resolverEnabled)(w, r)
65
65
-
return
66
66
-
}
67
67
-
68
68
-
// Handle DID routes if enabled
69
69
-
if resolverEnabled {
70
70
-
handleDIDRouting(w, r, mgr)
71
71
-
return
72
72
-
}
73
73
-
74
74
-
// 404 for everything else
75
75
-
sendJSON(w, 404, map[string]string{"error": "not found"})
76
76
-
})
77
77
-
78
78
-
// Wrap with CORS middleware
79
79
-
return corsMiddleware(mux)
80
80
-
}
81
81
-
82
82
-
// handleDIDRouting routes DID-related requests
83
83
-
func handleDIDRouting(w http.ResponseWriter, r *http.Request, mgr *bundle.Manager) {
84
84
-
path := strings.TrimPrefix(r.URL.Path, "/")
85
85
-
86
86
-
// Parse DID and sub-path
87
87
-
parts := strings.SplitN(path, "/", 2)
88
88
-
did := parts[0]
89
89
-
90
90
-
// Validate it's a DID
91
91
-
if !strings.HasPrefix(did, "did:plc:") {
92
92
-
sendJSON(w, 404, map[string]string{"error": "not found"})
93
93
-
return
94
94
-
}
95
95
-
96
96
-
// Route based on sub-path
97
97
-
if len(parts) == 1 {
98
98
-
// /did:plc:xxx -> DID document
99
99
-
handleDIDDocumentLatestNative(mgr, did)(w, r)
100
100
-
} else if parts[1] == "data" {
101
101
-
// /did:plc:xxx/data -> PLC state
102
102
-
handleDIDDataNative(mgr, did)(w, r)
103
103
-
} else if parts[1] == "log/audit" {
104
104
-
// /did:plc:xxx/log/audit -> Audit log
105
105
-
handleDIDAuditLogNative(mgr, did)(w, r)
106
106
-
} else {
107
107
-
sendJSON(w, 404, map[string]string{"error": "not found"})
108
108
-
}
109
109
-
}
110
110
-
111
111
-
// corsMiddleware adds CORS headers (skips WebSocket upgrade requests)
112
112
-
func corsMiddleware(next http.Handler) http.Handler {
113
113
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114
114
-
// Check if this is a WebSocket upgrade request
115
115
-
if r.Header.Get("Upgrade") == "websocket" {
116
116
-
// Skip CORS for WebSocket - pass through directly
117
117
-
next.ServeHTTP(w, r)
118
118
-
return
119
119
-
}
120
120
-
121
121
-
// Normal CORS handling for non-WebSocket requests
122
122
-
w.Header().Set("Access-Control-Allow-Origin", "*")
123
123
-
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
124
124
-
125
125
-
if requestedHeaders := r.Header.Get("Access-Control-Request-Headers"); requestedHeaders != "" {
126
126
-
w.Header().Set("Access-Control-Allow-Headers", requestedHeaders)
127
127
-
} else {
128
128
-
w.Header().Set("Access-Control-Allow-Headers", "*")
129
129
-
}
130
130
-
131
131
-
w.Header().Set("Access-Control-Max-Age", "86400")
132
132
-
133
133
-
if r.Method == "OPTIONS" {
134
134
-
w.WriteHeader(204)
135
135
-
return
136
136
-
}
137
137
-
138
138
-
next.ServeHTTP(w, r)
139
139
-
})
140
140
-
}
141
141
-
142
142
-
// sendJSON sends JSON response
143
143
-
func sendJSON(w http.ResponseWriter, statusCode int, data interface{}) {
144
144
-
w.Header().Set("Content-Type", "application/json")
145
145
-
146
146
-
jsonData, err := json.Marshal(data)
147
147
-
if err != nil {
148
148
-
w.WriteHeader(500)
149
149
-
w.Write([]byte(`{"error":"failed to marshal JSON"}`))
150
150
-
return
151
151
-
}
152
152
-
153
153
-
w.WriteHeader(statusCode)
154
154
-
w.Write(jsonData)
155
155
-
}
156
156
-
157
157
-
// Handler implementations
158
158
-
159
159
-
func handleRootNative(mgr *bundle.Manager, syncMode bool, wsEnabled bool, resolverEnabled bool) http.HandlerFunc {
160
160
-
return func(w http.ResponseWriter, r *http.Request) {
161
161
-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
162
162
-
163
163
-
index := mgr.GetIndex()
164
164
-
stats := index.GetStats()
165
165
-
bundleCount := stats["bundle_count"].(int)
166
166
-
167
167
-
baseURL := getBaseURL(r)
168
168
-
wsURL := getWSURL(r)
169
169
-
170
170
-
var sb strings.Builder
171
171
-
172
172
-
sb.WriteString(`
173
173
-
174
174
-
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⡀⠀⠀⠀⠀⠀⠀⢀⠀⠀⡀⠀⢀⠀⢀⡀⣤⡢⣤⡤⡀⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
175
175
-
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡄⡄⠐⡀⠈⣀⠀⡠⡠⠀⣢⣆⢌⡾⢙⠺⢽⠾⡋⣻⡷⡫⢵⣭⢦⣴⠦⠀⢠⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
176
176
-
⠀⠀⠀⠀⠀⠀⠀⠀⢠⣤⣽⣥⡈⠧⣂⢧⢾⠕⠞⠡⠊⠁⣐⠉⠀⠉⢍⠀⠉⠌⡉⠀⠂⠁⠱⠉⠁⢝⠻⠎⣬⢌⡌⣬⣡⣀⣢⣄⡄⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀
177
177
-
⠀⠀⠀⠀⠀⠀⠀⢀⢸⣿⣿⢿⣾⣯⣑⢄⡂⠀⠄⠂⠀⠀⢀⠀⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠄⠐⠀⠀⠀⠀⣄⠭⠂⠈⠜⣩⣿⢝⠃⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀
178
178
-
⠀⠀⠀⠀⠀⠀⠀⢀⣻⡟⠏⠀⠚⠈⠚⡉⡝⢶⣱⢤⣅⠈⠀⠄⠀⠀⠀⠀⠀⠠⠀⠀⡂⠐⣤⢕⡪⢼⣈⡹⡇⠏⠏⠋⠅⢃⣪⡏⡇⡍⠀⠀⠀⠀⠀⠀⠀⠀⠀
179
179
-
⠀⠀⠀⠀⠀⠀⠀⠀⠺⣻⡄⠀⠀⠀⢠⠌⠃⠐⠉⢡⠱⠧⠝⡯⣮⢶⣴⣤⡆⢐⣣⢅⣮⡟⠦⠍⠉⠀⠁⠐⠀⠀⠀⠄⠐⠡⣽⡸⣎⢁⠀⠀⠀⠀⠀⠀⠀⠀⠀
180
180
-
⠀⠀⠀⠀⠀⠀⠀⢈⡻⣧⠀⠁⠐⠀⠀⠀⠀⠀⠀⠊⠀⠕⢀⡉⠈⡫⠽⡿⡟⠿⠟⠁⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠬⠥⣋⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
181
181
-
⠀⠀⠀⠀⠀⠀⠀⡀⣾⡍⠕⡀⠀⠀⠀⠄⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠥⣤⢌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠄⢀⠀⢝⢞⣫⡆⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀
182
182
-
⠀⠀⠀⠀⠀⠀⠀⠀⣽⡶⡄⠐⡀⠀⠀⠀⠀⠀⠀⢀⠀⠄⠀⠀⠀⠄⠁⠇⣷⡆⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡸⢝⣮⠍⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
183
183
-
⠀⠀⠀⠀⠀⠀⢀⠀⢾⣷⠀⠠⡀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠁⡁⠀⠀⣾⡥⠖⠀⠀⠀⠂⠀⠀⠀⠀⠀⠁⠀⡀⠁⠀⠀⠻⢳⣻⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
184
184
-
⠀⠀⠀⠀⠀⠀⠀⠀⣞⡙⠨⣀⠠⠄⠀⠂⠀⠀⠀⠈⢀⠀⠀⠀⠀⠀⠤⢚⢢⣟⠀⠀⠀⠀⡐⠀⠀⡀⠀⠀⠀⠀⠁⠈⠌⠊⣯⣮⡏⠡⠂⠀⠀⠀⠀⠀⠀⠀⠀
185
185
-
⠀⠀⠀⠀⠀⠀⠀⠀⣻⡟⡄⡡⣄⠀⠠⠀⠀⡅⠀⠐⠀⡀⠀⡀⠀⠄⠈⠃⠳⠪⠤⠀⠀⠀⠀⡀⠀⠂⠀⠀⠀⠁⠈⢠⣠⠒⠻⣻⡧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
186
186
-
⠀⠀⠀⠀⠀⠀⠀⠀⠪⡎⠠⢌⠑⡀⠂⠀⠄⠠⠀⠠⠀⠁⡀⠠⠠⡀⣀⠜⢏⡅⠀⠀⡀⠁⠀⠀⠁⠁⠐⠄⡀⢀⠂⠀⠄⢑⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
187
187
-
⠀⠀⠀⠀⠀⠀⠀⠀⠼⣻⠧⣣⣀⠐⠨⠁⠕⢈⢀⢀⡁⠀⠈⠠⢀⠀⠐⠜⣽⡗⡤⠀⠂⠀⠠⠀⢂⠠⠀⠁⠄⠀⠔⠀⠑⣨⣿⢯⠋⡅⠀⠀⠀⠀⠀⠀⠀⠀⠀
188
188
-
⠀⠀⠀⠀⠀⠀⠀⠀⡚⣷⣭⠎⢃⡗⠄⡄⢀⠁⠀⠅⢀⢅⡀⠠⠀⢠⡀⡩⠷⢇⠀⡀⠄⡠⠤⠆⣀⡀⠄⠉⣠⠃⠴⠀⠈⢁⣿⡛⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
189
189
-
⠀⠀⠀⠀⠀⠀⠀⠘⡬⡿⣿⡏⡻⡯⠌⢁⢛⠠⠓⠐⠐⠐⠌⠃⠋⠂⡢⢰⣈⢏⣰⠂⠈⠀⠠⠒⠡⠌⠫⠭⠩⠢⡬⠆⠿⢷⢿⡽⡧⠉⠊⠀⠀⠀⠀⠀⠀⠀⠀
190
190
-
⠀⠀⠀⠀⠀⠀⠀⠀⠺⣷⣺⣗⣿⡶⡎⡅⣣⢎⠠⡅⣢⡖⠴⠬⡈⠂⡨⢡⠾⣣⣢⠀⠀⡹⠄⡄⠄⡇⣰⡖⡊⠔⢹⣄⣿⣭⣵⣿⢷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
191
191
-
⠀⠀⠀⠀⠀⠀⠀⠀⠩⣿⣿⣲⣿⣷⣟⣼⠟⣬⢉⡠⣪⢜⣂⣁⠥⠓⠚⡁⢶⣷⣠⠂⡄⡢⣀⡐⠧⢆⣒⡲⡳⡫⢟⡃⢪⡧⣟⡟⣯⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀
192
192
-
⠀⠀⠀⠀⠀⠀⠀⠀⢺⠟⢿⢟⢻⡗⡮⡿⣲⢷⣆⣏⣇⡧⣄⢖⠾⡷⣿⣤⢳⢷⣣⣦⡜⠗⣭⢂⠩⣹⢿⡲⢎⡧⣕⣖⣓⣽⡿⡖⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
193
193
-
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠂⠂⠏⠿⢻⣥⡪⢽⣳⣳⣥⡶⣫⣍⢐⣥⣻⣾⡻⣅⢭⡴⢭⣿⠕⣧⡭⣞⣻⣣⣻⢿⠟⠛⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
194
194
-
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠋⠫⠯⣍⢻⣿⣿⣷⣕⣵⣹⣽⣿⣷⣇⡏⣿⡿⣍⡝⠵⠯⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
195
195
-
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⠠⠁⠋⢣⠓⡍⣫⠹⣿⣿⣷⡿⠯⠺⠁⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
196
196
-
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠋⢀⠋⢈⡿⠿⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
197
197
-
198
198
-
plcbundle server
199
199
-
200
200
-
*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*
201
201
-
| ⚠️ Preview Version – Do Not Use In Production! |
202
202
-
*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*
203
203
-
| This project and plcbundle specification is currently |
204
204
-
| unstable and under heavy development. Things can break at |
205
205
-
| any time. Do not use this for production systems. |
206
206
-
| Please wait for the 1.0 release. |
207
207
-
|________________________________________________________________|
208
208
-
209
209
-
`)
210
210
-
211
211
-
sb.WriteString("\nplcbundle server\n\n")
212
212
-
sb.WriteString("What is PLC Bundle?\n")
213
213
-
sb.WriteString("━━━━━━━━━━━━━━━━━━━━\n")
214
214
-
sb.WriteString("plcbundle archives AT Protocol's DID PLC Directory operations into\n")
215
215
-
sb.WriteString("immutable, cryptographically-chained bundles of 10,000 operations.\n\n")
216
216
-
sb.WriteString("More info: https://tangled.org/@atscan.net/plcbundle\n\n")
217
217
-
218
218
-
if bundleCount > 0 {
219
219
-
sb.WriteString("Bundles\n")
220
220
-
sb.WriteString("━━━━━━━\n")
221
221
-
sb.WriteString(fmt.Sprintf(" Bundle count: %d\n", bundleCount))
222
222
-
223
223
-
firstBundle := stats["first_bundle"].(int)
224
224
-
lastBundle := stats["last_bundle"].(int)
225
225
-
totalSize := stats["total_size"].(int64)
226
226
-
totalUncompressed := stats["total_uncompressed_size"].(int64)
227
227
-
228
228
-
sb.WriteString(fmt.Sprintf(" Last bundle: %d (%s)\n", lastBundle,
229
229
-
stats["updated_at"].(time.Time).Format("2006-01-02 15:04:05")))
230
230
-
sb.WriteString(fmt.Sprintf(" Range: %06d - %06d\n", firstBundle, lastBundle))
231
231
-
sb.WriteString(fmt.Sprintf(" Total size: %.2f MB\n", float64(totalSize)/(1000*1000)))
232
232
-
sb.WriteString(fmt.Sprintf(" Uncompressed: %.2f MB (%.2fx)\n",
233
233
-
float64(totalUncompressed)/(1000*1000),
234
234
-
float64(totalUncompressed)/float64(totalSize)))
235
235
-
236
236
-
if gaps, ok := stats["gaps"].(int); ok && gaps > 0 {
237
237
-
sb.WriteString(fmt.Sprintf(" ⚠ Gaps: %d missing bundles\n", gaps))
238
238
-
}
239
239
-
240
240
-
firstMeta, err := index.GetBundle(firstBundle)
241
241
-
if err == nil {
242
242
-
sb.WriteString(fmt.Sprintf("\n Root: %s\n", firstMeta.Hash))
243
243
-
}
244
244
-
245
245
-
lastMeta, err := index.GetBundle(lastBundle)
246
246
-
if err == nil {
247
247
-
sb.WriteString(fmt.Sprintf(" Head: %s\n", lastMeta.Hash))
248
248
-
}
249
249
-
}
250
250
-
251
251
-
if syncMode {
252
252
-
mempoolStats := mgr.GetMempoolStats()
253
253
-
count := mempoolStats["count"].(int)
254
254
-
targetBundle := mempoolStats["target_bundle"].(int)
255
255
-
canCreate := mempoolStats["can_create_bundle"].(bool)
256
256
-
257
257
-
sb.WriteString("\nMempool Stats\n")
258
258
-
sb.WriteString("━━━━━━━━━━━━━\n")
259
259
-
sb.WriteString(fmt.Sprintf(" Target bundle: %d\n", targetBundle))
260
260
-
sb.WriteString(fmt.Sprintf(" Operations: %d / %d\n", count, types.BUNDLE_SIZE))
261
261
-
sb.WriteString(fmt.Sprintf(" Can create bundle: %v\n", canCreate))
262
262
-
263
263
-
if count > 0 {
264
264
-
progress := float64(count) / float64(types.BUNDLE_SIZE) * 100
265
265
-
sb.WriteString(fmt.Sprintf(" Progress: %.1f%%\n", progress))
266
266
-
267
267
-
barWidth := 50
268
268
-
filled := int(float64(barWidth) * float64(count) / float64(types.BUNDLE_SIZE))
269
269
-
if filled > barWidth {
270
270
-
filled = barWidth
271
271
-
}
272
272
-
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
273
273
-
sb.WriteString(fmt.Sprintf(" [%s]\n", bar))
274
274
-
275
275
-
if firstTime, ok := mempoolStats["first_time"].(time.Time); ok {
276
276
-
sb.WriteString(fmt.Sprintf(" First op: %s\n", firstTime.Format("2006-01-02 15:04:05")))
277
277
-
}
278
278
-
if lastTime, ok := mempoolStats["last_time"].(time.Time); ok {
279
279
-
sb.WriteString(fmt.Sprintf(" Last op: %s\n", lastTime.Format("2006-01-02 15:04:05")))
280
280
-
}
281
281
-
} else {
282
282
-
sb.WriteString(" (empty)\n")
283
283
-
}
284
284
-
}
285
285
-
286
286
-
if didStats := mgr.GetDIDIndexStats(); didStats["exists"].(bool) {
287
287
-
sb.WriteString("\nDID Index\n")
288
288
-
sb.WriteString("━━━━━━━━━\n")
289
289
-
sb.WriteString(" Status: enabled\n")
290
290
-
291
291
-
indexedDIDs := didStats["indexed_dids"].(int64)
292
292
-
mempoolDIDs := didStats["mempool_dids"].(int64)
293
293
-
totalDIDs := didStats["total_dids"].(int64)
294
294
-
295
295
-
if mempoolDIDs > 0 {
296
296
-
sb.WriteString(fmt.Sprintf(" Total DIDs: %s (%s indexed + %s mempool)\n",
297
297
-
formatNumber(int(totalDIDs)),
298
298
-
formatNumber(int(indexedDIDs)),
299
299
-
formatNumber(int(mempoolDIDs))))
300
300
-
} else {
301
301
-
sb.WriteString(fmt.Sprintf(" Total DIDs: %s\n", formatNumber(int(totalDIDs))))
302
302
-
}
303
303
-
304
304
-
sb.WriteString(fmt.Sprintf(" Cached shards: %d / %d\n",
305
305
-
didStats["cached_shards"], didStats["cache_limit"]))
306
306
-
sb.WriteString("\n")
307
307
-
}
308
308
-
309
309
-
sb.WriteString("Server Stats\n")
310
310
-
sb.WriteString("━━━━━━━━━━━━\n")
311
311
-
sb.WriteString(fmt.Sprintf(" Version: %s\n", version))
312
312
-
if origin := mgr.GetPLCOrigin(); origin != "" {
313
313
-
sb.WriteString(fmt.Sprintf(" Origin: %s\n", origin))
314
314
-
}
315
315
-
sb.WriteString(fmt.Sprintf(" Sync mode: %v\n", syncMode))
316
316
-
sb.WriteString(fmt.Sprintf(" WebSocket: %v\n", wsEnabled))
317
317
-
sb.WriteString(fmt.Sprintf(" Resolver: %v\n", resolverEnabled))
318
318
-
sb.WriteString(fmt.Sprintf(" Uptime: %s\n", time.Since(serverStartTime).Round(time.Second)))
319
319
-
320
320
-
sb.WriteString("\n\nAPI Endpoints\n")
321
321
-
sb.WriteString("━━━━━━━━━━━━━\n")
322
322
-
sb.WriteString(" GET / This info page\n")
323
323
-
sb.WriteString(" GET /index.json Full bundle index\n")
324
324
-
sb.WriteString(" GET /bundle/:number Bundle metadata (JSON)\n")
325
325
-
sb.WriteString(" GET /data/:number Raw bundle (zstd compressed)\n")
326
326
-
sb.WriteString(" GET /jsonl/:number Decompressed JSONL stream\n")
327
327
-
sb.WriteString(" GET /status Server status\n")
328
328
-
sb.WriteString(" GET /mempool Mempool operations (JSONL)\n")
329
329
-
330
330
-
if resolverEnabled {
331
331
-
sb.WriteString("\nDID Resolution\n")
332
332
-
sb.WriteString("━━━━━━━━━━━━━━\n")
333
333
-
sb.WriteString(" GET /:did DID Document (W3C format)\n")
334
334
-
sb.WriteString(" GET /:did/data PLC State (raw format)\n")
335
335
-
sb.WriteString(" GET /:did/log/audit Operation history\n")
336
336
-
337
337
-
didStats := mgr.GetDIDIndexStats()
338
338
-
if didStats["exists"].(bool) {
339
339
-
sb.WriteString(fmt.Sprintf("\n Index: %s DIDs indexed\n",
340
340
-
formatNumber(int(didStats["total_dids"].(int64)))))
341
341
-
} else {
342
342
-
sb.WriteString("\n ⚠️ Index: not built (will use slow scan)\n")
343
343
-
}
344
344
-
sb.WriteString("\n")
345
345
-
}
346
346
-
347
347
-
if wsEnabled {
348
348
-
sb.WriteString("\nWebSocket Endpoints\n")
349
349
-
sb.WriteString("━━━━━━━━━━━━━━━━━━━\n")
350
350
-
sb.WriteString(" WS /ws Live stream (new operations only)\n")
351
351
-
sb.WriteString(" WS /ws?cursor=0 Stream all from beginning\n")
352
352
-
sb.WriteString(" WS /ws?cursor=N Stream from cursor N\n\n")
353
353
-
sb.WriteString("Cursor Format:\n")
354
354
-
sb.WriteString(" Global record number: (bundleNumber × 10,000) + position\n")
355
355
-
sb.WriteString(" Example: 88410345 = bundle 8841, position 345\n")
356
356
-
sb.WriteString(" Default: starts from latest (skips all historical data)\n")
357
357
-
358
358
-
latestCursor := mgr.GetCurrentCursor()
359
359
-
bundledOps := len(index.GetBundles()) * types.BUNDLE_SIZE
360
360
-
mempoolOps := latestCursor - bundledOps
361
361
-
362
362
-
if syncMode && mempoolOps > 0 {
363
363
-
sb.WriteString(fmt.Sprintf(" Current latest: %d (%d bundled + %d mempool)\n",
364
364
-
latestCursor, bundledOps, mempoolOps))
365
365
-
} else {
366
366
-
sb.WriteString(fmt.Sprintf(" Current latest: %d (%d bundles)\n",
367
367
-
latestCursor, len(index.GetBundles())))
368
368
-
}
369
369
-
}
370
370
-
371
371
-
sb.WriteString("\nExamples\n")
372
372
-
sb.WriteString("━━━━━━━━\n")
373
373
-
sb.WriteString(fmt.Sprintf(" curl %s/bundle/1\n", baseURL))
374
374
-
sb.WriteString(fmt.Sprintf(" curl %s/data/42 -o 000042.jsonl.zst\n", baseURL))
375
375
-
sb.WriteString(fmt.Sprintf(" curl %s/jsonl/1\n", baseURL))
376
376
-
377
377
-
if wsEnabled {
378
378
-
sb.WriteString(fmt.Sprintf(" websocat %s/ws\n", wsURL))
379
379
-
sb.WriteString(fmt.Sprintf(" websocat '%s/ws?cursor=0'\n", wsURL))
380
380
-
}
381
381
-
382
382
-
if syncMode {
383
383
-
sb.WriteString(fmt.Sprintf(" curl %s/status\n", baseURL))
384
384
-
sb.WriteString(fmt.Sprintf(" curl %s/mempool\n", baseURL))
385
385
-
}
386
386
-
387
387
-
sb.WriteString("\n────────────────────────────────────────────────────────────────\n")
388
388
-
sb.WriteString("https://tangled.org/@atscan.net/plcbundle\n")
389
389
-
390
390
-
w.Write([]byte(sb.String()))
391
391
-
}
392
392
-
}
393
393
-
394
394
-
func handleIndexJSONNative(mgr *bundle.Manager) http.HandlerFunc {
395
395
-
return func(w http.ResponseWriter, r *http.Request) {
396
396
-
index := mgr.GetIndex()
397
397
-
sendJSON(w, 200, index)
398
398
-
}
399
399
-
}
400
400
-
401
401
-
func handleBundleNative(mgr *bundle.Manager) http.HandlerFunc {
402
402
-
return func(w http.ResponseWriter, r *http.Request) {
403
403
-
bundleNum, err := strconv.Atoi(r.PathValue("number"))
404
404
-
if err != nil {
405
405
-
sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"})
406
406
-
return
407
407
-
}
408
408
-
409
409
-
meta, err := mgr.GetIndex().GetBundle(bundleNum)
410
410
-
if err != nil {
411
411
-
sendJSON(w, 404, map[string]string{"error": "Bundle not found"})
412
412
-
return
413
413
-
}
414
414
-
415
415
-
sendJSON(w, 200, meta)
416
416
-
}
417
417
-
}
418
418
-
419
419
-
func handleBundleDataNative(mgr *bundle.Manager) http.HandlerFunc {
420
420
-
return func(w http.ResponseWriter, r *http.Request) {
421
421
-
bundleNum, err := strconv.Atoi(r.PathValue("number"))
422
422
-
if err != nil {
423
423
-
sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"})
424
424
-
return
425
425
-
}
426
426
-
427
427
-
reader, err := mgr.StreamBundleRaw(context.Background(), bundleNum)
428
428
-
if err != nil {
429
429
-
if strings.Contains(err.Error(), "not in index") || strings.Contains(err.Error(), "not found") {
430
430
-
sendJSON(w, 404, map[string]string{"error": "Bundle not found"})
431
431
-
} else {
432
432
-
sendJSON(w, 500, map[string]string{"error": err.Error()})
433
433
-
}
434
434
-
return
435
435
-
}
436
436
-
defer reader.Close()
437
437
-
438
438
-
w.Header().Set("Content-Type", "application/zstd")
439
439
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundleNum))
440
440
-
441
441
-
io.Copy(w, reader)
442
442
-
}
443
443
-
}
444
444
-
445
445
-
func handleBundleJSONLNative(mgr *bundle.Manager) http.HandlerFunc {
446
446
-
return func(w http.ResponseWriter, r *http.Request) {
447
447
-
bundleNum, err := strconv.Atoi(r.PathValue("number"))
448
448
-
if err != nil {
449
449
-
sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"})
450
450
-
return
451
451
-
}
452
452
-
453
453
-
reader, err := mgr.StreamBundleDecompressed(context.Background(), bundleNum)
454
454
-
if err != nil {
455
455
-
if strings.Contains(err.Error(), "not in index") || strings.Contains(err.Error(), "not found") {
456
456
-
sendJSON(w, 404, map[string]string{"error": "Bundle not found"})
457
457
-
} else {
458
458
-
sendJSON(w, 500, map[string]string{"error": err.Error()})
459
459
-
}
460
460
-
return
461
461
-
}
462
462
-
defer reader.Close()
463
463
-
464
464
-
w.Header().Set("Content-Type", "application/x-ndjson")
465
465
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundleNum))
466
466
-
467
467
-
io.Copy(w, reader)
468
468
-
}
469
469
-
}
470
470
-
471
471
-
func handleStatusNative(mgr *bundle.Manager, syncMode bool, wsEnabled bool) http.HandlerFunc {
472
472
-
return func(w http.ResponseWriter, r *http.Request) {
473
473
-
index := mgr.GetIndex()
474
474
-
indexStats := index.GetStats()
475
475
-
476
476
-
response := StatusResponse{
477
477
-
Server: ServerStatus{
478
478
-
Version: version,
479
479
-
UptimeSeconds: int(time.Since(serverStartTime).Seconds()),
480
480
-
SyncMode: syncMode,
481
481
-
WebSocketEnabled: wsEnabled,
482
482
-
Origin: mgr.GetPLCOrigin(),
483
483
-
},
484
484
-
Bundles: BundleStatus{
485
485
-
Count: indexStats["bundle_count"].(int),
486
486
-
TotalSize: indexStats["total_size"].(int64),
487
487
-
UncompressedSize: indexStats["total_uncompressed_size"].(int64),
488
488
-
},
489
489
-
}
490
490
-
491
491
-
if syncMode && syncInterval > 0 {
492
492
-
response.Server.SyncIntervalSeconds = int(syncInterval.Seconds())
493
493
-
}
494
494
-
495
495
-
if bundleCount := response.Bundles.Count; bundleCount > 0 {
496
496
-
firstBundle := indexStats["first_bundle"].(int)
497
497
-
lastBundle := indexStats["last_bundle"].(int)
498
498
-
499
499
-
response.Bundles.FirstBundle = firstBundle
500
500
-
response.Bundles.LastBundle = lastBundle
501
501
-
response.Bundles.StartTime = indexStats["start_time"].(time.Time)
502
502
-
response.Bundles.EndTime = indexStats["end_time"].(time.Time)
503
503
-
504
504
-
if firstMeta, err := index.GetBundle(firstBundle); err == nil {
505
505
-
response.Bundles.RootHash = firstMeta.Hash
506
506
-
}
507
507
-
508
508
-
if lastMeta, err := index.GetBundle(lastBundle); err == nil {
509
509
-
response.Bundles.HeadHash = lastMeta.Hash
510
510
-
response.Bundles.HeadAgeSeconds = int(time.Since(lastMeta.EndTime).Seconds())
511
511
-
}
512
512
-
513
513
-
if gaps, ok := indexStats["gaps"].(int); ok {
514
514
-
response.Bundles.Gaps = gaps
515
515
-
response.Bundles.HasGaps = gaps > 0
516
516
-
if gaps > 0 {
517
517
-
response.Bundles.GapNumbers = index.FindGaps()
518
518
-
}
519
519
-
}
520
520
-
521
521
-
totalOps := bundleCount * types.BUNDLE_SIZE
522
522
-
response.Bundles.TotalOperations = totalOps
523
523
-
524
524
-
duration := response.Bundles.EndTime.Sub(response.Bundles.StartTime)
525
525
-
if duration.Hours() > 0 {
526
526
-
response.Bundles.AvgOpsPerHour = int(float64(totalOps) / duration.Hours())
527
527
-
}
528
528
-
}
529
529
-
530
530
-
if syncMode {
531
531
-
mempoolStats := mgr.GetMempoolStats()
532
532
-
533
533
-
if count, ok := mempoolStats["count"].(int); ok {
534
534
-
mempool := &MempoolStatus{
535
535
-
Count: count,
536
536
-
TargetBundle: mempoolStats["target_bundle"].(int),
537
537
-
CanCreateBundle: mempoolStats["can_create_bundle"].(bool),
538
538
-
MinTimestamp: mempoolStats["min_timestamp"].(time.Time),
539
539
-
Validated: mempoolStats["validated"].(bool),
540
540
-
ProgressPercent: float64(count) / float64(types.BUNDLE_SIZE) * 100,
541
541
-
BundleSize: types.BUNDLE_SIZE,
542
542
-
OperationsNeeded: types.BUNDLE_SIZE - count,
543
543
-
}
544
544
-
545
545
-
if firstTime, ok := mempoolStats["first_time"].(time.Time); ok {
546
546
-
mempool.FirstTime = firstTime
547
547
-
mempool.TimespanSeconds = int(time.Since(firstTime).Seconds())
548
548
-
}
549
549
-
if lastTime, ok := mempoolStats["last_time"].(time.Time); ok {
550
550
-
mempool.LastTime = lastTime
551
551
-
mempool.LastOpAgeSeconds = int(time.Since(lastTime).Seconds())
552
552
-
}
553
553
-
554
554
-
if count > 100 && count < types.BUNDLE_SIZE {
555
555
-
if !mempool.FirstTime.IsZero() && !mempool.LastTime.IsZero() {
556
556
-
timespan := mempool.LastTime.Sub(mempool.FirstTime)
557
557
-
if timespan.Seconds() > 0 {
558
558
-
opsPerSec := float64(count) / timespan.Seconds()
559
559
-
remaining := types.BUNDLE_SIZE - count
560
560
-
mempool.EtaNextBundleSeconds = int(float64(remaining) / opsPerSec)
561
561
-
}
562
562
-
}
563
563
-
}
564
564
-
565
565
-
response.Mempool = mempool
566
566
-
}
567
567
-
}
568
568
-
569
569
-
sendJSON(w, 200, response)
570
570
-
}
571
571
-
}
572
572
-
573
573
-
func handleMempoolNative(mgr *bundle.Manager) http.HandlerFunc {
574
574
-
return func(w http.ResponseWriter, r *http.Request) {
575
575
-
ops, err := mgr.GetMempoolOperations()
576
576
-
if err != nil {
577
577
-
sendJSON(w, 500, map[string]string{"error": err.Error()})
578
578
-
return
579
579
-
}
580
580
-
581
581
-
w.Header().Set("Content-Type", "application/x-ndjson")
582
582
-
583
583
-
if len(ops) == 0 {
584
584
-
return
585
585
-
}
586
586
-
587
587
-
for _, op := range ops {
588
588
-
if len(op.RawJSON) > 0 {
589
589
-
w.Write(op.RawJSON)
590
590
-
} else {
591
591
-
data, _ := json.Marshal(op)
592
592
-
w.Write(data)
593
593
-
}
594
594
-
w.Write([]byte("\n"))
595
595
-
}
596
596
-
}
597
597
-
}
598
598
-
599
599
-
func handleDebugMemoryNative(mgr *bundle.Manager) http.HandlerFunc {
600
600
-
return func(w http.ResponseWriter, r *http.Request) {
601
601
-
var m runtime.MemStats
602
602
-
runtime.ReadMemStats(&m)
603
603
-
604
604
-
didStats := mgr.GetDIDIndexStats()
605
605
-
606
606
-
beforeAlloc := m.Alloc / 1024 / 1024
607
607
-
608
608
-
runtime.GC()
609
609
-
runtime.ReadMemStats(&m)
610
610
-
afterAlloc := m.Alloc / 1024 / 1024
611
611
-
612
612
-
response := fmt.Sprintf(`Memory Stats:
613
613
-
Alloc: %d MB
614
614
-
TotalAlloc: %d MB
615
615
-
Sys: %d MB
616
616
-
NumGC: %d
617
617
-
618
618
-
DID Index:
619
619
-
Cached shards: %d/%d
620
620
-
621
621
-
After GC:
622
622
-
Alloc: %d MB
623
623
-
`,
624
624
-
beforeAlloc,
625
625
-
m.TotalAlloc/1024/1024,
626
626
-
m.Sys/1024/1024,
627
627
-
m.NumGC,
628
628
-
didStats["cached_shards"],
629
629
-
didStats["cache_limit"],
630
630
-
afterAlloc)
631
631
-
632
632
-
w.Header().Set("Content-Type", "text/plain")
633
633
-
w.Write([]byte(response))
634
634
-
}
635
635
-
}
636
636
-
637
637
-
func handleWebSocketNative(mgr *bundle.Manager) http.HandlerFunc {
638
638
-
return func(w http.ResponseWriter, r *http.Request) {
639
639
-
cursorStr := r.URL.Query().Get("cursor")
640
640
-
var cursor int
641
641
-
642
642
-
if cursorStr == "" {
643
643
-
cursor = mgr.GetCurrentCursor()
644
644
-
} else {
645
645
-
var err error
646
646
-
cursor, err = strconv.Atoi(cursorStr)
647
647
-
if err != nil || cursor < 0 {
648
648
-
http.Error(w, "Invalid cursor: must be non-negative integer", 400)
649
649
-
return
650
650
-
}
651
651
-
}
652
652
-
653
653
-
conn, err := upgrader.Upgrade(w, r, nil)
654
654
-
if err != nil {
655
655
-
fmt.Fprintf(os.Stderr, "WebSocket upgrade failed: %v\n", err)
656
656
-
return
657
657
-
}
658
658
-
defer conn.Close()
659
659
-
660
660
-
conn.SetPongHandler(func(string) error {
661
661
-
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
662
662
-
return nil
663
663
-
})
664
664
-
665
665
-
done := make(chan struct{})
666
666
-
667
667
-
go func() {
668
668
-
defer close(done)
669
669
-
for {
670
670
-
_, _, err := conn.ReadMessage()
671
671
-
if err != nil {
672
672
-
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
673
673
-
fmt.Fprintf(os.Stderr, "WebSocket: client closed connection\n")
674
674
-
}
675
675
-
return
676
676
-
}
677
677
-
}
678
678
-
}()
679
679
-
680
680
-
bgCtx := context.Background()
681
681
-
682
682
-
if err := streamLive(bgCtx, conn, mgr, cursor, done); err != nil {
683
683
-
fmt.Fprintf(os.Stderr, "WebSocket stream error: %v\n", err)
684
684
-
}
685
685
-
}
686
686
-
}
687
687
-
688
688
-
func handleDIDDocumentLatestNative(mgr *bundle.Manager, did string) http.HandlerFunc {
689
689
-
return func(w http.ResponseWriter, r *http.Request) {
690
690
-
op, err := mgr.GetLatestDIDOperation(context.Background(), did)
691
691
-
if err != nil {
692
692
-
sendJSON(w, 500, map[string]string{"error": err.Error()})
693
693
-
return
694
694
-
}
695
695
-
696
696
-
doc, err := plcclient.ResolveDIDDocument(did, []plcclient.PLCOperation{*op})
697
697
-
if err != nil {
698
698
-
if strings.Contains(err.Error(), "deactivated") {
699
699
-
sendJSON(w, 410, map[string]string{"error": "DID has been deactivated"})
700
700
-
} else {
701
701
-
sendJSON(w, 500, map[string]string{"error": fmt.Sprintf("Resolution failed: %v", err)})
702
702
-
}
703
703
-
return
704
704
-
}
705
705
-
706
706
-
w.Header().Set("Content-Type", "application/did+ld+json")
707
707
-
sendJSON(w, 200, doc)
708
708
-
}
709
709
-
}
710
710
-
711
711
-
func handleDIDDataNative(mgr *bundle.Manager, did string) http.HandlerFunc {
712
712
-
return func(w http.ResponseWriter, r *http.Request) {
713
713
-
if err := plcclient.ValidateDIDFormat(did); err != nil {
714
714
-
sendJSON(w, 400, map[string]string{"error": "Invalid DID format"})
715
715
-
return
716
716
-
}
717
717
-
718
718
-
operations, err := mgr.GetDIDOperations(context.Background(), did, false)
719
719
-
if err != nil {
720
720
-
sendJSON(w, 500, map[string]string{"error": err.Error()})
721
721
-
return
722
722
-
}
723
723
-
724
724
-
if len(operations) == 0 {
725
725
-
sendJSON(w, 404, map[string]string{"error": "DID not found"})
726
726
-
return
727
727
-
}
728
728
-
729
729
-
state, err := plcclient.BuildDIDState(did, operations)
730
730
-
if err != nil {
731
731
-
if strings.Contains(err.Error(), "deactivated") {
732
732
-
sendJSON(w, 410, map[string]string{"error": "DID has been deactivated"})
733
733
-
} else {
734
734
-
sendJSON(w, 500, map[string]string{"error": err.Error()})
735
735
-
}
736
736
-
return
737
737
-
}
738
738
-
739
739
-
sendJSON(w, 200, state)
740
740
-
}
741
741
-
}
742
742
-
743
743
-
func handleDIDAuditLogNative(mgr *bundle.Manager, did string) http.HandlerFunc {
744
744
-
return func(w http.ResponseWriter, r *http.Request) {
745
745
-
if err := plcclient.ValidateDIDFormat(did); err != nil {
746
746
-
sendJSON(w, 400, map[string]string{"error": "Invalid DID format"})
747
747
-
return
748
748
-
}
749
749
-
750
750
-
operations, err := mgr.GetDIDOperations(context.Background(), did, false)
751
751
-
if err != nil {
752
752
-
sendJSON(w, 500, map[string]string{"error": err.Error()})
753
753
-
return
754
754
-
}
755
755
-
756
756
-
if len(operations) == 0 {
757
757
-
sendJSON(w, 404, map[string]string{"error": "DID not found"})
758
758
-
return
759
759
-
}
760
760
-
761
761
-
auditLog := plcclient.FormatAuditLog(operations)
762
762
-
sendJSON(w, 200, auditLog)
763
763
-
}
764
764
-
}
765
765
-
766
766
-
// WebSocket streaming functions (unchanged from your original)
767
767
-
768
768
-
func streamLive(ctx context.Context, conn *websocket.Conn, mgr *bundle.Manager, startCursor int, done chan struct{}) error {
769
769
-
index := mgr.GetIndex()
770
770
-
bundles := index.GetBundles()
771
771
-
currentRecord := startCursor
772
772
-
773
773
-
if len(bundles) > 0 {
774
774
-
startBundleIdx := startCursor / types.BUNDLE_SIZE
775
775
-
startPosition := startCursor % types.BUNDLE_SIZE
776
776
-
777
777
-
if startBundleIdx < len(bundles) {
778
778
-
for i := startBundleIdx; i < len(bundles); i++ {
779
779
-
skipUntil := 0
780
780
-
if i == startBundleIdx {
781
781
-
skipUntil = startPosition
782
782
-
}
783
783
-
784
784
-
newRecordCount, err := streamBundle(ctx, conn, mgr, bundles[i].BundleNumber, skipUntil, done)
785
785
-
if err != nil {
786
786
-
return err
787
787
-
}
788
788
-
currentRecord += newRecordCount
789
789
-
}
790
790
-
}
791
791
-
}
792
792
-
793
793
-
lastSeenMempoolCount := 0
794
794
-
if err := streamMempool(conn, mgr, startCursor, len(bundles)*types.BUNDLE_SIZE, ¤tRecord, &lastSeenMempoolCount, done); err != nil {
795
795
-
return err
796
796
-
}
797
797
-
798
798
-
ticker := time.NewTicker(500 * time.Millisecond)
799
799
-
defer ticker.Stop()
800
800
-
801
801
-
lastBundleCount := len(bundles)
802
802
-
if verboseMode {
803
803
-
fmt.Fprintf(os.Stderr, "WebSocket: entering live mode at cursor %d\n", currentRecord)
804
804
-
}
805
805
-
806
806
-
for {
807
807
-
select {
808
808
-
case <-done:
809
809
-
if verboseMode {
810
810
-
fmt.Fprintf(os.Stderr, "WebSocket: client disconnected, stopping stream\n")
811
811
-
}
812
812
-
return nil
813
813
-
814
814
-
case <-ticker.C:
815
815
-
index = mgr.GetIndex()
816
816
-
bundles = index.GetBundles()
817
817
-
818
818
-
if len(bundles) > lastBundleCount {
819
819
-
newBundleCount := len(bundles) - lastBundleCount
820
820
-
821
821
-
if verboseMode {
822
822
-
fmt.Fprintf(os.Stderr, "WebSocket: %d new bundle(s) created (operations already streamed from mempool)\n", newBundleCount)
823
823
-
}
824
824
-
825
825
-
currentRecord += newBundleCount * types.BUNDLE_SIZE
826
826
-
lastBundleCount = len(bundles)
827
827
-
lastSeenMempoolCount = 0
828
828
-
}
829
829
-
830
830
-
if err := streamMempool(conn, mgr, startCursor, len(bundles)*types.BUNDLE_SIZE, ¤tRecord, &lastSeenMempoolCount, done); err != nil {
831
831
-
return err
832
832
-
}
833
833
-
834
834
-
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
835
835
-
return err
836
836
-
}
837
837
-
}
838
838
-
}
839
839
-
}
840
840
-
841
841
-
func streamBundle(ctx context.Context, conn *websocket.Conn, mgr *bundle.Manager, bundleNumber int, skipUntil int, done chan struct{}) (int, error) {
842
842
-
reader, err := mgr.StreamBundleDecompressed(ctx, bundleNumber)
843
843
-
if err != nil {
844
844
-
fmt.Fprintf(os.Stderr, "Failed to stream bundle %d: %v\n", bundleNumber, err)
845
845
-
return 0, nil
846
846
-
}
847
847
-
defer reader.Close()
848
848
-
849
849
-
scanner := bufio.NewScanner(reader)
850
850
-
buf := make([]byte, 0, 64*1024)
851
851
-
scanner.Buffer(buf, 1024*1024)
852
852
-
853
853
-
position := 0
854
854
-
streamed := 0
855
855
-
856
856
-
for scanner.Scan() {
857
857
-
line := scanner.Bytes()
858
858
-
if len(line) == 0 {
859
859
-
continue
860
860
-
}
861
861
-
862
862
-
if position < skipUntil {
863
863
-
position++
864
864
-
continue
865
865
-
}
866
866
-
867
867
-
select {
868
868
-
case <-done:
869
869
-
return streamed, nil
870
870
-
default:
871
871
-
}
872
872
-
873
873
-
if err := conn.WriteMessage(websocket.TextMessage, line); err != nil {
874
874
-
return streamed, err
875
875
-
}
876
876
-
877
877
-
position++
878
878
-
streamed++
879
879
-
880
880
-
if streamed%1000 == 0 {
881
881
-
conn.WriteMessage(websocket.PingMessage, nil)
882
882
-
}
883
883
-
}
884
884
-
885
885
-
if err := scanner.Err(); err != nil {
886
886
-
return streamed, fmt.Errorf("scanner error on bundle %d: %w", bundleNumber, err)
887
887
-
}
888
888
-
889
889
-
return streamed, nil
890
890
-
}
891
891
-
892
892
-
func streamMempool(conn *websocket.Conn, mgr *bundle.Manager, startCursor int, bundleRecordBase int, currentRecord *int, lastSeenCount *int, done chan struct{}) error {
893
893
-
mempoolOps, err := mgr.GetMempoolOperations()
894
894
-
if err != nil {
895
895
-
return nil
896
896
-
}
897
897
-
898
898
-
if len(mempoolOps) <= *lastSeenCount {
899
899
-
return nil
900
900
-
}
901
901
-
902
902
-
newOps := len(mempoolOps) - *lastSeenCount
903
903
-
if newOps > 0 && verboseMode {
904
904
-
fmt.Fprintf(os.Stderr, "WebSocket: streaming %d new mempool operation(s)\n", newOps)
905
905
-
}
906
906
-
907
907
-
for i := *lastSeenCount; i < len(mempoolOps); i++ {
908
908
-
recordNum := bundleRecordBase + i
909
909
-
if recordNum < startCursor {
910
910
-
continue
911
911
-
}
912
912
-
913
913
-
select {
914
914
-
case <-done:
915
915
-
return nil
916
916
-
default:
917
917
-
}
918
918
-
919
919
-
if err := sendOperation(conn, mempoolOps[i]); err != nil {
920
920
-
return err
921
921
-
}
922
922
-
*currentRecord++
923
923
-
}
924
924
-
925
925
-
*lastSeenCount = len(mempoolOps)
926
926
-
return nil
927
927
-
}
928
928
-
929
929
-
func sendOperation(conn *websocket.Conn, op plcclient.PLCOperation) error {
930
930
-
var data []byte
931
931
-
var err error
932
932
-
933
933
-
if len(op.RawJSON) > 0 {
934
934
-
data = op.RawJSON
935
935
-
} else {
936
936
-
data, err = json.Marshal(op)
937
937
-
if err != nil {
938
938
-
fmt.Fprintf(os.Stderr, "Failed to marshal operation: %v\n", err)
939
939
-
return nil
940
940
-
}
941
941
-
}
942
942
-
943
943
-
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
944
944
-
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
945
945
-
fmt.Fprintf(os.Stderr, "WebSocket write error: %v\n", err)
946
946
-
}
947
947
-
return err
948
948
-
}
949
949
-
950
950
-
return nil
951
951
-
}
952
952
-
953
953
-
// Helper functions
954
954
-
955
955
-
func getScheme(r *http.Request) string {
956
956
-
if r.TLS != nil {
957
957
-
return "https"
958
958
-
}
959
959
-
960
960
-
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
961
961
-
return proto
962
962
-
}
963
963
-
964
964
-
if r.Header.Get("X-Forwarded-Ssl") == "on" {
965
965
-
return "https"
966
966
-
}
967
967
-
968
968
-
return "http"
969
969
-
}
970
970
-
971
971
-
func getWSScheme(r *http.Request) string {
972
972
-
if getScheme(r) == "https" {
973
973
-
return "wss"
974
974
-
}
975
975
-
return "ws"
976
976
-
}
977
977
-
978
978
-
func getBaseURL(r *http.Request) string {
979
979
-
scheme := getScheme(r)
980
980
-
host := r.Host
981
981
-
return fmt.Sprintf("%s://%s", scheme, host)
982
982
-
}
983
983
-
984
984
-
func getWSURL(r *http.Request) string {
985
985
-
scheme := getWSScheme(r)
986
986
-
host := r.Host
987
987
-
return fmt.Sprintf("%s://%s", scheme, host)
988
988
-
}
989
989
-
990
990
-
// Response types (unchanged)
991
991
-
992
992
-
type StatusResponse struct {
993
993
-
Bundles BundleStatus `json:"bundles"`
994
994
-
Mempool *MempoolStatus `json:"mempool,omitempty"`
995
995
-
Server ServerStatus `json:"server"`
996
996
-
}
997
997
-
998
998
-
type ServerStatus struct {
999
999
-
Version string `json:"version"`
1000
1000
-
UptimeSeconds int `json:"uptime_seconds"`
1001
1001
-
SyncMode bool `json:"sync_mode"`
1002
1002
-
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
1003
1003
-
WebSocketEnabled bool `json:"websocket_enabled"`
1004
1004
-
Origin string `json:"origin,omitempty"`
1005
1005
-
}
1006
1006
-
1007
1007
-
type BundleStatus struct {
1008
1008
-
Count int `json:"count"`
1009
1009
-
FirstBundle int `json:"first_bundle,omitempty"`
1010
1010
-
LastBundle int `json:"last_bundle,omitempty"`
1011
1011
-
TotalSize int64 `json:"total_size"`
1012
1012
-
UncompressedSize int64 `json:"uncompressed_size,omitempty"`
1013
1013
-
CompressionRatio float64 `json:"compression_ratio,omitempty"`
1014
1014
-
TotalOperations int `json:"total_operations,omitempty"`
1015
1015
-
AvgOpsPerHour int `json:"avg_ops_per_hour,omitempty"`
1016
1016
-
StartTime time.Time `json:"start_time,omitempty"`
1017
1017
-
EndTime time.Time `json:"end_time,omitempty"`
1018
1018
-
UpdatedAt time.Time `json:"updated_at"`
1019
1019
-
HeadAgeSeconds int `json:"head_age_seconds,omitempty"`
1020
1020
-
RootHash string `json:"root_hash,omitempty"`
1021
1021
-
HeadHash string `json:"head_hash,omitempty"`
1022
1022
-
Gaps int `json:"gaps,omitempty"`
1023
1023
-
HasGaps bool `json:"has_gaps"`
1024
1024
-
GapNumbers []int `json:"gap_numbers,omitempty"`
1025
1025
-
}
1026
1026
-
1027
1027
-
type MempoolStatus struct {
1028
1028
-
Count int `json:"count"`
1029
1029
-
TargetBundle int `json:"target_bundle"`
1030
1030
-
CanCreateBundle bool `json:"can_create_bundle"`
1031
1031
-
MinTimestamp time.Time `json:"min_timestamp"`
1032
1032
-
Validated bool `json:"validated"`
1033
1033
-
ProgressPercent float64 `json:"progress_percent"`
1034
1034
-
BundleSize int `json:"bundle_size"`
1035
1035
-
OperationsNeeded int `json:"operations_needed"`
1036
1036
-
FirstTime time.Time `json:"first_time,omitempty"`
1037
1037
-
LastTime time.Time `json:"last_time,omitempty"`
1038
1038
-
TimespanSeconds int `json:"timespan_seconds,omitempty"`
1039
1039
-
LastOpAgeSeconds int `json:"last_op_age_seconds,omitempty"`
1040
1040
-
EtaNextBundleSeconds int `json:"eta_next_bundle_seconds,omitempty"`
1041
1041
-
}
1042
1042
-
1043
1043
-
// Background sync (unchanged)
1044
1044
-
1045
1045
-
func runSync(ctx context.Context, mgr *bundle.Manager, interval time.Duration, verbose bool, resolverEnabled bool) {
1046
1046
-
syncBundles(ctx, mgr, verbose, resolverEnabled)
1047
1047
-
1048
1048
-
fmt.Fprintf(os.Stderr, "[Sync] Starting sync loop (interval: %s)\n", interval)
1049
1049
-
1050
1050
-
ticker := time.NewTicker(interval)
1051
1051
-
defer ticker.Stop()
1052
1052
-
1053
1053
-
saveTicker := time.NewTicker(5 * time.Minute)
1054
1054
-
defer saveTicker.Stop()
1055
1055
-
1056
1056
-
for {
1057
1057
-
select {
1058
1058
-
case <-ctx.Done():
1059
1059
-
if err := mgr.SaveMempool(); err != nil {
1060
1060
-
fmt.Fprintf(os.Stderr, "[Sync] Failed to save mempool: %v\n", err)
1061
1061
-
}
1062
1062
-
fmt.Fprintf(os.Stderr, "[Sync] Stopped\n")
1063
1063
-
return
1064
1064
-
1065
1065
-
case <-ticker.C:
1066
1066
-
syncBundles(ctx, mgr, verbose, resolverEnabled)
1067
1067
-
1068
1068
-
case <-saveTicker.C:
1069
1069
-
stats := mgr.GetMempoolStats()
1070
1070
-
if stats["count"].(int) > 0 && verbose {
1071
1071
-
fmt.Fprintf(os.Stderr, "[Sync] Saving mempool (%d ops)\n", stats["count"])
1072
1072
-
mgr.SaveMempool()
1073
1073
-
}
1074
1074
-
}
1075
1075
-
}
1076
1076
-
}
1077
1077
-
1078
1078
-
func syncBundles(ctx context.Context, mgr *bundle.Manager, verbose bool, resolverEnabled bool) {
1079
1079
-
cycleStart := time.Now()
1080
1080
-
1081
1081
-
index := mgr.GetIndex()
1082
1082
-
lastBundle := index.GetLastBundle()
1083
1083
-
startBundle := 1
1084
1084
-
if lastBundle != nil {
1085
1085
-
startBundle = lastBundle.BundleNumber + 1
1086
1086
-
}
1087
1087
-
1088
1088
-
isInitialSync := (lastBundle == nil || lastBundle.BundleNumber < 10)
1089
1089
-
1090
1090
-
if isInitialSync && !verbose {
1091
1091
-
fmt.Fprintf(os.Stderr, "[Sync] Initial sync - fast loading mode (bundle %06d → ...)\n", startBundle)
1092
1092
-
} else if verbose {
1093
1093
-
fmt.Fprintf(os.Stderr, "[Sync] Checking for new bundles (current: %06d)...\n", startBundle-1)
1094
1094
-
}
1095
1095
-
1096
1096
-
mempoolBefore := mgr.GetMempoolStats()["count"].(int)
1097
1097
-
fetchedCount := 0
1098
1098
-
consecutiveErrors := 0
1099
1099
-
1100
1100
-
for {
1101
1101
-
currentBundle := startBundle + fetchedCount
1102
1102
-
1103
1103
-
b, err := mgr.FetchNextBundle(ctx, !verbose)
1104
1104
-
if err != nil {
1105
1105
-
if isEndOfDataError(err) {
1106
1106
-
mempoolAfter := mgr.GetMempoolStats()["count"].(int)
1107
1107
-
addedOps := mempoolAfter - mempoolBefore
1108
1108
-
duration := time.Since(cycleStart)
1109
1109
-
1110
1110
-
if fetchedCount > 0 {
1111
1111
-
fmt.Fprintf(os.Stderr, "[Sync] ✓ Bundle %06d | Synced: %d | Mempool: %d (+%d) | %dms\n",
1112
1112
-
currentBundle-1, fetchedCount, mempoolAfter, addedOps, duration.Milliseconds())
1113
1113
-
} else if !isInitialSync {
1114
1114
-
fmt.Fprintf(os.Stderr, "[Sync] ✓ Bundle %06d | Up to date | Mempool: %d (+%d) | %dms\n",
1115
1115
-
startBundle-1, mempoolAfter, addedOps, duration.Milliseconds())
1116
1116
-
}
1117
1117
-
break
1118
1118
-
}
1119
1119
-
1120
1120
-
consecutiveErrors++
1121
1121
-
if verbose {
1122
1122
-
fmt.Fprintf(os.Stderr, "[Sync] Error fetching bundle %06d: %v\n", currentBundle, err)
1123
1123
-
}
1124
1124
-
1125
1125
-
if consecutiveErrors >= 3 {
1126
1126
-
fmt.Fprintf(os.Stderr, "[Sync] Too many errors, stopping\n")
1127
1127
-
break
1128
1128
-
}
1129
1129
-
1130
1130
-
time.Sleep(5 * time.Second)
1131
1131
-
continue
1132
1132
-
}
1133
1133
-
1134
1134
-
consecutiveErrors = 0
1135
1135
-
1136
1136
-
if err := mgr.SaveBundle(ctx, b, !verbose); err != nil {
1137
1137
-
fmt.Fprintf(os.Stderr, "[Sync] Error saving bundle %06d: %v\n", b.BundleNumber, err)
1138
1138
-
break
1139
1139
-
}
1140
1140
-
1141
1141
-
fetchedCount++
1142
1142
-
1143
1143
-
if !verbose {
1144
1144
-
fmt.Fprintf(os.Stderr, "[Sync] ✓ %06d | hash=%s | content=%s | %d ops, %d DIDs\n",
1145
1145
-
b.BundleNumber,
1146
1146
-
b.Hash[:16]+"...",
1147
1147
-
b.ContentHash[:16]+"...",
1148
1148
-
len(b.Operations),
1149
1149
-
b.DIDCount)
1150
1150
-
}
1151
1151
-
1152
1152
-
time.Sleep(500 * time.Millisecond)
1153
1153
-
}
1154
1154
-
}
1155
1155
-
1156
1156
-
func isEndOfDataError(err error) bool {
1157
1157
-
if err == nil {
1158
1158
-
return false
1159
1159
-
}
1160
1160
-
1161
1161
-
errMsg := err.Error()
1162
1162
-
return strings.Contains(errMsg, "insufficient operations") ||
1163
1163
-
strings.Contains(errMsg, "no more operations available") ||
1164
1164
-
strings.Contains(errMsg, "reached latest data")
1165
1165
-
}
+132
cmd/plcbundle/ui/progress.go
···
1
1
+
package ui
2
2
+
3
3
+
import (
4
4
+
"fmt"
5
5
+
"os"
6
6
+
"strings"
7
7
+
"sync"
8
8
+
"time"
9
9
+
)
10
10
+
11
11
+
// ProgressBar shows progress of an operation
12
12
+
type ProgressBar struct {
13
13
+
total int
14
14
+
current int
15
15
+
totalBytes int64
16
16
+
currentBytes int64
17
17
+
startTime time.Time
18
18
+
mu sync.Mutex
19
19
+
width int
20
20
+
lastPrint time.Time
21
21
+
showBytes bool
22
22
+
}
23
23
+
24
24
+
// NewProgressBar creates a new progress bar
25
25
+
func NewProgressBar(total int) *ProgressBar {
26
26
+
return &ProgressBar{
27
27
+
total: total,
28
28
+
startTime: time.Now(),
29
29
+
width: 40,
30
30
+
lastPrint: time.Now(),
31
31
+
showBytes: false,
32
32
+
}
33
33
+
}
34
34
+
35
35
+
// NewProgressBarWithBytes creates a new progress bar that tracks bytes
36
36
+
func NewProgressBarWithBytes(total int, totalBytes int64) *ProgressBar {
37
37
+
return &ProgressBar{
38
38
+
total: total,
39
39
+
totalBytes: totalBytes,
40
40
+
startTime: time.Now(),
41
41
+
width: 40,
42
42
+
lastPrint: time.Now(),
43
43
+
showBytes: true,
44
44
+
}
45
45
+
}
46
46
+
47
47
+
// Set sets the current progress
48
48
+
func (pb *ProgressBar) Set(current int) {
49
49
+
pb.mu.Lock()
50
50
+
defer pb.mu.Unlock()
51
51
+
pb.current = current
52
52
+
pb.print()
53
53
+
}
54
54
+
55
55
+
// SetWithBytes sets progress with byte tracking
56
56
+
func (pb *ProgressBar) SetWithBytes(current int, bytesProcessed int64) {
57
57
+
pb.mu.Lock()
58
58
+
defer pb.mu.Unlock()
59
59
+
pb.current = current
60
60
+
pb.currentBytes = bytesProcessed
61
61
+
pb.showBytes = true
62
62
+
pb.print()
63
63
+
}
64
64
+
65
65
+
// Finish completes the progress bar
66
66
+
func (pb *ProgressBar) Finish() {
67
67
+
pb.mu.Lock()
68
68
+
defer pb.mu.Unlock()
69
69
+
pb.current = pb.total
70
70
+
pb.currentBytes = pb.totalBytes
71
71
+
pb.print()
72
72
+
fmt.Fprintf(os.Stderr, "\n")
73
73
+
}
74
74
+
75
75
+
// print renders the progress bar
76
76
+
func (pb *ProgressBar) print() {
77
77
+
if time.Since(pb.lastPrint) < 100*time.Millisecond && pb.current < pb.total {
78
78
+
return
79
79
+
}
80
80
+
pb.lastPrint = time.Now()
81
81
+
82
82
+
percent := 0.0
83
83
+
if pb.total > 0 {
84
84
+
percent = float64(pb.current) / float64(pb.total) * 100
85
85
+
}
86
86
+
87
87
+
filled := 0
88
88
+
if pb.total > 0 {
89
89
+
filled = int(float64(pb.width) * float64(pb.current) / float64(pb.total))
90
90
+
if filled > pb.width {
91
91
+
filled = pb.width
92
92
+
}
93
93
+
}
94
94
+
95
95
+
bar := strings.Repeat("█", filled) + strings.Repeat("░", pb.width-filled)
96
96
+
97
97
+
elapsed := time.Since(pb.startTime)
98
98
+
speed := 0.0
99
99
+
if elapsed.Seconds() > 0 {
100
100
+
speed = float64(pb.current) / elapsed.Seconds()
101
101
+
}
102
102
+
103
103
+
remaining := pb.total - pb.current
104
104
+
var eta time.Duration
105
105
+
if speed > 0 {
106
106
+
eta = time.Duration(float64(remaining)/speed) * time.Second
107
107
+
}
108
108
+
109
109
+
if pb.showBytes && pb.currentBytes > 0 {
110
110
+
mbProcessed := float64(pb.currentBytes) / (1000 * 1000)
111
111
+
mbPerSec := mbProcessed / elapsed.Seconds()
112
112
+
113
113
+
fmt.Fprintf(os.Stderr, "\r [%s] %6.2f%% | %d/%d | %.1f/s | %.1f MB/s | ETA: %s ",
114
114
+
bar, percent, pb.current, pb.total, speed, mbPerSec, formatETA(eta))
115
115
+
} else {
116
116
+
fmt.Fprintf(os.Stderr, "\r [%s] %6.2f%% | %d/%d | %.1f/s | ETA: %s ",
117
117
+
bar, percent, pb.current, pb.total, speed, formatETA(eta))
118
118
+
}
119
119
+
}
120
120
+
121
121
+
func formatETA(d time.Duration) string {
122
122
+
if d == 0 {
123
123
+
return "calculating..."
124
124
+
}
125
125
+
if d < time.Minute {
126
126
+
return fmt.Sprintf("%ds", int(d.Seconds()))
127
127
+
}
128
128
+
if d < time.Hour {
129
129
+
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
130
130
+
}
131
131
+
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
132
132
+
}
+28
-6
internal/bundle/manager.go
···
1358
1358
1359
1359
if lastBundle != nil {
1360
1360
nextBundleNum = lastBundle.BundleNumber + 1
1361
1361
-
afterTime = lastBundle.EndTime.Format(time.RFC3339Nano)
1362
1361
prevBundleHash = lastBundle.Hash
1363
1362
1363
1363
+
// ✨ FIX: Use mempool's last operation time if available
1364
1364
+
// This prevents re-fetching operations already in mempool
1365
1365
+
mempoolLastTime := m.mempool.GetLastTime()
1366
1366
+
if mempoolLastTime != "" {
1367
1367
+
afterTime = mempoolLastTime
1368
1368
+
if !quiet {
1369
1369
+
m.logger.Printf("Using mempool cursor: %s", afterTime)
1370
1370
+
}
1371
1371
+
} else {
1372
1372
+
// No mempool operations yet, use last bundle
1373
1373
+
afterTime = lastBundle.EndTime.Format(time.RFC3339Nano)
1374
1374
+
}
1375
1375
+
1364
1376
prevBundle, err := m.LoadBundle(ctx, lastBundle.BundleNumber)
1365
1377
if err == nil {
1366
1378
_, prevBoundaryCIDs = m.operations.GetBoundaryCIDs(prevBundle.Operations)
···
1371
1383
m.logger.Printf("Preparing bundle %06d (mempool: %d ops)...", nextBundleNum, m.mempool.Count())
1372
1384
}
1373
1385
1374
1374
-
// Fetch operations using syncer
1375
1375
-
for m.mempool.Count() < types.BUNDLE_SIZE {
1386
1386
+
// Fetch in a loop until we have enough OR hit end-of-data
1387
1387
+
maxAttempts := 10
1388
1388
+
attemptCount := 0
1389
1389
+
1390
1390
+
for m.mempool.Count() < types.BUNDLE_SIZE && attemptCount < maxAttempts {
1391
1391
+
attemptCount++
1392
1392
+
1376
1393
newOps, err := m.syncer.FetchToMempool(
1377
1394
ctx,
1378
1395
afterTime,
···
1394
1411
return nil, fmt.Errorf("chronological validation failed: %w", err)
1395
1412
}
1396
1413
1397
1397
-
if !quiet {
1414
1414
+
if !quiet && added > 0 {
1398
1415
m.logger.Printf("Added %d new operations (mempool now: %d)", added, m.mempool.Count())
1399
1416
}
1400
1417
1401
1401
-
if len(newOps) == 0 {
1418
1418
+
// ✨ Update cursor to last operation in mempool
1419
1419
+
afterTime = m.mempool.GetLastTime()
1420
1420
+
1421
1421
+
// If we got no new operations, we've caught up
1422
1422
+
if len(newOps) == 0 || added == 0 {
1402
1423
break
1403
1424
}
1404
1425
}
1405
1426
1406
1427
if m.mempool.Count() < types.BUNDLE_SIZE {
1407
1428
m.mempool.Save()
1408
1408
-
return nil, fmt.Errorf("insufficient operations: have %d, need %d", m.mempool.Count(), types.BUNDLE_SIZE)
1429
1429
+
return nil, fmt.Errorf("insufficient operations: have %d, need %d (reached latest data)",
1430
1430
+
m.mempool.Count(), types.BUNDLE_SIZE)
1409
1431
}
1410
1432
1411
1433
// Create bundle
+6
-11
internal/sync/fetcher.go
···
27
27
}
28
28
29
29
// FetchToMempool fetches operations and returns them
30
30
-
// Returns: operations, error
31
30
func (f *Fetcher) FetchToMempool(
32
31
ctx context.Context,
33
32
afterTime string,
···
49
48
var allNewOps []plcclient.PLCOperation
50
49
51
50
for fetchNum := 0; fetchNum < maxFetches; fetchNum++ {
52
52
-
// Calculate batch size
53
51
remaining := target - len(allNewOps)
54
52
if remaining <= 0 {
55
53
break
···
61
59
}
62
60
63
61
if !quiet {
64
64
-
f.logger.Printf(" Fetch #%d: requesting %d operations",
65
65
-
fetchNum+1, batchSize)
62
62
+
f.logger.Printf(" Fetch #%d: requesting %d operations", fetchNum+1, batchSize)
66
63
}
67
64
68
65
batch, err := f.plcClient.Export(ctx, plcclient.ExportOptions{
···
77
74
if !quiet {
78
75
f.logger.Printf(" No more operations available from PLC")
79
76
}
80
80
-
if len(allNewOps) > 0 {
81
81
-
return allNewOps, nil
82
82
-
}
83
83
-
return nil, fmt.Errorf("no operations available")
77
77
+
break
84
78
}
85
79
86
86
-
// Deduplicate
80
80
+
// Deduplicate against boundary CIDs only
81
81
+
// Mempool will handle deduplication of operations already in mempool
87
82
for _, op := range batch {
88
83
if !seenCIDs[op.CID] {
89
84
seenCIDs[op.CID] = true
···
96
91
currentAfter = batch[len(batch)-1].CreatedAt.Format(time.RFC3339Nano)
97
92
}
98
93
99
99
-
// Stop if we got less than requested
94
94
+
// Stop if we got less than requested (caught up)
100
95
if len(batch) < batchSize {
101
96
if !quiet {
102
97
f.logger.Printf(" Received incomplete batch (%d/%d), caught up to latest", len(batch), batchSize)
···
112
107
return allNewOps, nil
113
108
}
114
109
115
115
-
return nil, fmt.Errorf("no new operations added")
110
110
+
return nil, fmt.Errorf("no operations available (reached latest data)")
116
111
}
+54
options.go
···
1
1
+
package plcbundle
2
2
+
3
3
+
import (
4
4
+
"tangled.org/atscan.net/plcbundle/internal/bundle"
5
5
+
"tangled.org/atscan.net/plcbundle/plcclient"
6
6
+
)
7
7
+
8
8
+
type config struct {
9
9
+
bundleConfig *bundle.Config
10
10
+
plcClient *plcclient.Client
11
11
+
}
12
12
+
13
13
+
func defaultConfig() *config {
14
14
+
return &config{
15
15
+
bundleConfig: bundle.DefaultConfig("./plc_bundles"),
16
16
+
}
17
17
+
}
18
18
+
19
19
+
// Option configures the Manager
20
20
+
type Option func(*config)
21
21
+
22
22
+
// WithDirectory sets the bundle storage directory
23
23
+
func WithDirectory(dir string) Option {
24
24
+
return func(c *config) {
25
25
+
c.bundleConfig.BundleDir = dir
26
26
+
}
27
27
+
}
28
28
+
29
29
+
// WithPLCDirectory sets the PLC directory URL
30
30
+
func WithPLCDirectory(url string) Option {
31
31
+
return func(c *config) {
32
32
+
c.plcClient = plcclient.NewClient(url)
33
33
+
}
34
34
+
}
35
35
+
36
36
+
// WithVerifyOnLoad enables/disables hash verification when loading bundles
37
37
+
func WithVerifyOnLoad(verify bool) Option {
38
38
+
return func(c *config) {
39
39
+
c.bundleConfig.VerifyOnLoad = verify
40
40
+
}
41
41
+
}
42
42
+
43
43
+
// WithLogger sets a custom logger
44
44
+
func WithLogger(logger Logger) Option {
45
45
+
return func(c *config) {
46
46
+
c.bundleConfig.Logger = logger
47
47
+
}
48
48
+
}
49
49
+
50
50
+
// Logger interface
51
51
+
type Logger interface {
52
52
+
Printf(format string, v ...interface{})
53
53
+
Println(v ...interface{})
54
54
+
}
+595
server/handlers.go
···
1
1
+
package server
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"io"
7
7
+
"net/http"
8
8
+
"runtime"
9
9
+
"strconv"
10
10
+
"strings"
11
11
+
"time"
12
12
+
13
13
+
"github.com/goccy/go-json"
14
14
+
"tangled.org/atscan.net/plcbundle/internal/types"
15
15
+
"tangled.org/atscan.net/plcbundle/plcclient"
16
16
+
)
17
17
+
18
18
+
func (s *Server) handleRoot() http.HandlerFunc {
19
19
+
return func(w http.ResponseWriter, r *http.Request) {
20
20
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
21
21
+
22
22
+
index := s.manager.GetIndex()
23
23
+
stats := index.GetStats()
24
24
+
bundleCount := stats["bundle_count"].(int)
25
25
+
26
26
+
baseURL := getBaseURL(r)
27
27
+
wsURL := getWSURL(r)
28
28
+
29
29
+
var sb strings.Builder
30
30
+
31
31
+
sb.WriteString(`
32
32
+
33
33
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠀⡀⠀⠀⠀⠀⠀⠀⢀⠀⠀⡀⠀⢀⠀⢀⡀⣤⡢⣤⡤⡀⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
34
34
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⡄⡄⠐⡀⠈⣀⠀⡠⡠⠀⣢⣆⢌⡾⢙⠺⢽⠾⡋⣻⡷⡫⢵⣭⢦⣴⠦⠀⢠⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
35
35
+
⠀⠀⠀⠀⠀⠀⠀⠀⢠⣤⣽⣥⡈⠧⣂⢧⢾⠕⠞⠡⠊⠁⣐⠉⠀⠉⢍⠀⠉⠌⡉⠀⠂⠁⠱⠉⠁⢝⠻⠎⣬⢌⡌⣬⣡⣀⣢⣄⡄⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀
36
36
+
⠀⠀⠀⠀⠀⠀⠀⢀⢸⣿⣿⢿⣾⣯⣑⢄⡂⠀⠄⠂⠀⠀⢀⠀⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠄⠐⠀⠀⠀⠀⣄⠭⠂⠈⠜⣩⣿⢝⠃⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀
37
37
+
⠀⠀⠀⠀⠀⠀⠀⢀⣻⡟⠏⠀⠚⠈⠚⡉⡝⢶⣱⢤⣅⠈⠀⠄⠀⠀⠀⠀⠀⠠⠀⠀⡂⠐⣤⢕⡪⢼⣈⡹⡇⠏⠏⠋⠅⢃⣪⡏⡇⡍⠀⠀⠀⠀⠀⠀⠀⠀⠀
38
38
+
⠀⠀⠀⠀⠀⠀⠀⠀⠺⣻⡄⠀⠀⠀⢠⠌⠃⠐⠉⢡⠱⠧⠝⡯⣮⢶⣴⣤⡆⢐⣣⢅⣮⡟⠦⠍⠉⠀⠁⠐⠀⠀⠀⠄⠐⠡⣽⡸⣎⢁⠀⠀⠀⠀⠀⠀⠀⠀⠀
39
39
+
⠀⠀⠀⠀⠀⠀⠀⢈⡻⣧⠀⠁⠐⠀⠀⠀⠀⠀⠀⠊⠀⠕⢀⡉⠈⡫⠽⡿⡟⠿⠟⠁⠀⠀⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠬⠥⣋⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
40
40
+
⠀⠀⠀⠀⠀⠀⠀⡀⣾⡍⠕⡀⠀⠀⠀⠄⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠥⣤⢌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠄⢀⠀⢝⢞⣫⡆⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀
41
41
+
⠀⠀⠀⠀⠀⠀⠀⠀⣽⡶⡄⠐⡀⠀⠀⠀⠀⠀⠀⢀⠀⠄⠀⠀⠀⠄⠁⠇⣷⡆⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡸⢝⣮⠍⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
42
42
+
⠀⠀⠀⠀⠀⠀⢀⠀⢾⣷⠀⠠⡀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠁⡁⠀⠀⣾⡥⠖⠀⠀⠀⠂⠀⠀⠀⠀⠀⠁⠀⡀⠁⠀⠀⠻⢳⣻⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
43
43
+
⠀⠀⠀⠀⠀⠀⠀⠀⣞⡙⠨⣀⠠⠄⠀⠂⠀⠀⠀⠈⢀⠀⠀⠀⠀⠀⠤⢚⢢⣟⠀⠀⠀⠀⡐⠀⠀⡀⠀⠀⠀⠀⠁⠈⠌⠊⣯⣮⡏⠡⠂⠀⠀⠀⠀⠀⠀⠀⠀
44
44
+
⠀⠀⠀⠀⠀⠀⠀⠀⣻⡟⡄⡡⣄⠀⠠⠀⠀⡅⠀⠐⠀⡀⠀⡀⠀⠄⠈⠃⠳⠪⠤⠀⠀⠀⠀⡀⠀⠂⠀⠀⠀⠁⠈⢠⣠⠒⠻⣻⡧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
45
45
+
⠀⠀⠀⠀⠀⠀⠀⠀⠪⡎⠠⢌⠑⡀⠂⠀⠄⠠⠀⠠⠀⠁⡀⠠⠠⡀⣀⠜⢏⡅⠀⠀⡀⠁⠀⠀⠁⠁⠐⠄⡀⢀⠂⠀⠄⢑⣿⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
46
46
+
⠀⠀⠀⠀⠀⠀⠀⠀⠼⣻⠧⣣⣀⠐⠨⠁⠕⢈⢀⢀⡁⠀⠈⠠⢀⠀⠐⠜⣽⡗⡤⠀⠂⠀⠠⠀⢂⠠⠀⠁⠄⠀⠔⠀⠑⣨⣿⢯⠋⡅⠀⠀⠀⠀⠀⠀⠀⠀⠀
47
47
+
⠀⠀⠀⠀⠀⠀⠀⠀⡚⣷⣭⠎⢃⡗⠄⡄⢀⠁⠀⠅⢀⢅⡀⠠⠀⢠⡀⡩⠷⢇⠀⡀⠄⡠⠤⠆⣀⡀⠄⠉⣠⠃⠴⠀⠈⢁⣿⡛⡯⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
48
48
+
⠀⠀⠀⠀⠀⠀⠀⠘⡬⡿⣿⡏⡻⡯⠌⢁⢛⠠⠓⠐⠐⠐⠌⠃⠋⠂⡢⢰⣈⢏⣰⠂⠈⠀⠠⠒⠡⠌⠫⠭⠩⠢⡬⠆⠿⢷⢿⡽⡧⠉⠊⠀⠀⠀⠀⠀⠀⠀⠀
49
49
+
⠀⠀⠀⠀⠀⠀⠀⠀⠺⣷⣺⣗⣿⡶⡎⡅⣣⢎⠠⡅⣢⡖⠴⠬⡈⠂⡨⢡⠾⣣⣢⠀⠀⡹⠄⡄⠄⡇⣰⡖⡊⠔⢹⣄⣿⣭⣵⣿⢷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
50
50
+
⠀⠀⠀⠀⠀⠀⠀⠀⠩⣿⣿⣲⣿⣷⣟⣼⠟⣬⢉⡠⣪⢜⣂⣁⠥⠓⠚⡁⢶⣷⣠⠂⡄⡢⣀⡐⠧⢆⣒⡲⡳⡫⢟⡃⢪⡧⣟⡟⣯⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀
51
51
+
⠀⠀⠀⠀⠀⠀⠀⠀⢺⠟⢿⢟⢻⡗⡮⡿⣲⢷⣆⣏⣇⡧⣄⢖⠾⡷⣿⣤⢳⢷⣣⣦⡜⠗⣭⢂⠩⣹⢿⡲⢎⡧⣕⣖⣓⣽⡿⡖⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
52
52
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠂⠂⠏⠿⢻⣥⡪⢽⣳⣳⣥⡶⣫⣍⢐⣥⣻⣾⡻⣅⢭⡴⢭⣿⠕⣧⡭⣞⣻⣣⣻⢿⠟⠛⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
53
53
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠄⠋⠫⠯⣍⢻⣿⣿⣷⣕⣵⣹⣽⣿⣷⣇⡏⣿⡿⣍⡝⠵⠯⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
54
54
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⠠⠁⠋⢣⠓⡍⣫⠹⣿⣿⣷⡿⠯⠺⠁⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
55
55
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠋⢀⠋⢈⡿⠿⠁⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
56
56
+
57
57
+
plcbundle server
58
58
+
59
59
+
*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*
60
60
+
| ⚠️ Preview Version – Do Not Use In Production! |
61
61
+
*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*
62
62
+
| This project and plcbundle specification is currently |
63
63
+
| unstable and under heavy development. Things can break at |
64
64
+
| any time. Do not use this for production systems. |
65
65
+
| Please wait for the 1.0 release. |
66
66
+
|________________________________________________________________|
67
67
+
68
68
+
`)
69
69
+
70
70
+
sb.WriteString("\nplcbundle server\n\n")
71
71
+
sb.WriteString("What is PLC Bundle?\n")
72
72
+
sb.WriteString("━━━━━━━━━━━━━━━━━━━━\n")
73
73
+
sb.WriteString("plcbundle archives AT Protocol's DID PLC Directory operations into\n")
74
74
+
sb.WriteString("immutable, cryptographically-chained bundles of 10,000 operations.\n\n")
75
75
+
sb.WriteString("More info: https://tangled.org/@atscan.net/plcbundle\n\n")
76
76
+
77
77
+
if bundleCount > 0 {
78
78
+
sb.WriteString("Bundles\n")
79
79
+
sb.WriteString("━━━━━━━\n")
80
80
+
sb.WriteString(fmt.Sprintf(" Bundle count: %d\n", bundleCount))
81
81
+
82
82
+
firstBundle := stats["first_bundle"].(int)
83
83
+
lastBundle := stats["last_bundle"].(int)
84
84
+
totalSize := stats["total_size"].(int64)
85
85
+
totalUncompressed := stats["total_uncompressed_size"].(int64)
86
86
+
87
87
+
sb.WriteString(fmt.Sprintf(" Last bundle: %d (%s)\n", lastBundle,
88
88
+
stats["updated_at"].(time.Time).Format("2006-01-02 15:04:05")))
89
89
+
sb.WriteString(fmt.Sprintf(" Range: %06d - %06d\n", firstBundle, lastBundle))
90
90
+
sb.WriteString(fmt.Sprintf(" Total size: %.2f MB\n", float64(totalSize)/(1000*1000)))
91
91
+
sb.WriteString(fmt.Sprintf(" Uncompressed: %.2f MB (%.2fx)\n",
92
92
+
float64(totalUncompressed)/(1000*1000),
93
93
+
float64(totalUncompressed)/float64(totalSize)))
94
94
+
95
95
+
if gaps, ok := stats["gaps"].(int); ok && gaps > 0 {
96
96
+
sb.WriteString(fmt.Sprintf(" ⚠ Gaps: %d missing bundles\n", gaps))
97
97
+
}
98
98
+
99
99
+
firstMeta, err := index.GetBundle(firstBundle)
100
100
+
if err == nil {
101
101
+
sb.WriteString(fmt.Sprintf("\n Root: %s\n", firstMeta.Hash))
102
102
+
}
103
103
+
104
104
+
lastMeta, err := index.GetBundle(lastBundle)
105
105
+
if err == nil {
106
106
+
sb.WriteString(fmt.Sprintf(" Head: %s\n", lastMeta.Hash))
107
107
+
}
108
108
+
}
109
109
+
110
110
+
if s.config.SyncMode {
111
111
+
mempoolStats := s.manager.GetMempoolStats()
112
112
+
count := mempoolStats["count"].(int)
113
113
+
targetBundle := mempoolStats["target_bundle"].(int)
114
114
+
canCreate := mempoolStats["can_create_bundle"].(bool)
115
115
+
116
116
+
sb.WriteString("\nMempool Stats\n")
117
117
+
sb.WriteString("━━━━━━━━━━━━━\n")
118
118
+
sb.WriteString(fmt.Sprintf(" Target bundle: %d\n", targetBundle))
119
119
+
sb.WriteString(fmt.Sprintf(" Operations: %d / %d\n", count, types.BUNDLE_SIZE))
120
120
+
sb.WriteString(fmt.Sprintf(" Can create bundle: %v\n", canCreate))
121
121
+
122
122
+
if count > 0 {
123
123
+
progress := float64(count) / float64(types.BUNDLE_SIZE) * 100
124
124
+
sb.WriteString(fmt.Sprintf(" Progress: %.1f%%\n", progress))
125
125
+
126
126
+
barWidth := 50
127
127
+
filled := int(float64(barWidth) * float64(count) / float64(types.BUNDLE_SIZE))
128
128
+
if filled > barWidth {
129
129
+
filled = barWidth
130
130
+
}
131
131
+
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
132
132
+
sb.WriteString(fmt.Sprintf(" [%s]\n", bar))
133
133
+
134
134
+
if firstTime, ok := mempoolStats["first_time"].(time.Time); ok {
135
135
+
sb.WriteString(fmt.Sprintf(" First op: %s\n", firstTime.Format("2006-01-02 15:04:05")))
136
136
+
}
137
137
+
if lastTime, ok := mempoolStats["last_time"].(time.Time); ok {
138
138
+
sb.WriteString(fmt.Sprintf(" Last op: %s\n", lastTime.Format("2006-01-02 15:04:05")))
139
139
+
}
140
140
+
} else {
141
141
+
sb.WriteString(" (empty)\n")
142
142
+
}
143
143
+
}
144
144
+
145
145
+
if didStats := s.manager.GetDIDIndexStats(); didStats["exists"].(bool) {
146
146
+
sb.WriteString("\nDID Index\n")
147
147
+
sb.WriteString("━━━━━━━━━\n")
148
148
+
sb.WriteString(" Status: enabled\n")
149
149
+
150
150
+
indexedDIDs := didStats["indexed_dids"].(int64)
151
151
+
mempoolDIDs := didStats["mempool_dids"].(int64)
152
152
+
totalDIDs := didStats["total_dids"].(int64)
153
153
+
154
154
+
if mempoolDIDs > 0 {
155
155
+
sb.WriteString(fmt.Sprintf(" Total DIDs: %s (%s indexed + %s mempool)\n",
156
156
+
formatNumber(int(totalDIDs)),
157
157
+
formatNumber(int(indexedDIDs)),
158
158
+
formatNumber(int(mempoolDIDs))))
159
159
+
} else {
160
160
+
sb.WriteString(fmt.Sprintf(" Total DIDs: %s\n", formatNumber(int(totalDIDs))))
161
161
+
}
162
162
+
163
163
+
sb.WriteString(fmt.Sprintf(" Cached shards: %d / %d\n",
164
164
+
didStats["cached_shards"], didStats["cache_limit"]))
165
165
+
sb.WriteString("\n")
166
166
+
}
167
167
+
168
168
+
sb.WriteString("Server Stats\n")
169
169
+
sb.WriteString("━━━━━━━━━━━━\n")
170
170
+
sb.WriteString(fmt.Sprintf(" Version: %s\n", s.config.Version))
171
171
+
if origin := s.manager.GetPLCOrigin(); origin != "" {
172
172
+
sb.WriteString(fmt.Sprintf(" Origin: %s\n", origin))
173
173
+
}
174
174
+
sb.WriteString(fmt.Sprintf(" Sync mode: %v\n", s.config.SyncMode))
175
175
+
sb.WriteString(fmt.Sprintf(" WebSocket: %v\n", s.config.EnableWebSocket))
176
176
+
sb.WriteString(fmt.Sprintf(" Resolver: %v\n", s.config.EnableResolver))
177
177
+
sb.WriteString(fmt.Sprintf(" Uptime: %s\n", time.Since(s.startTime).Round(time.Second)))
178
178
+
179
179
+
sb.WriteString("\n\nAPI Endpoints\n")
180
180
+
sb.WriteString("━━━━━━━━━━━━━\n")
181
181
+
sb.WriteString(" GET / This info page\n")
182
182
+
sb.WriteString(" GET /index.json Full bundle index\n")
183
183
+
sb.WriteString(" GET /bundle/:number Bundle metadata (JSON)\n")
184
184
+
sb.WriteString(" GET /data/:number Raw bundle (zstd compressed)\n")
185
185
+
sb.WriteString(" GET /jsonl/:number Decompressed JSONL stream\n")
186
186
+
sb.WriteString(" GET /status Server status\n")
187
187
+
sb.WriteString(" GET /mempool Mempool operations (JSONL)\n")
188
188
+
189
189
+
if s.config.EnableResolver {
190
190
+
sb.WriteString("\nDID Resolution\n")
191
191
+
sb.WriteString("━━━━━━━━━━━━━━\n")
192
192
+
sb.WriteString(" GET /:did DID Document (W3C format)\n")
193
193
+
sb.WriteString(" GET /:did/data PLC State (raw format)\n")
194
194
+
sb.WriteString(" GET /:did/log/audit Operation history\n")
195
195
+
196
196
+
didStats := s.manager.GetDIDIndexStats()
197
197
+
if didStats["exists"].(bool) {
198
198
+
sb.WriteString(fmt.Sprintf("\n Index: %s DIDs indexed\n",
199
199
+
formatNumber(int(didStats["total_dids"].(int64)))))
200
200
+
} else {
201
201
+
sb.WriteString("\n ⚠️ Index: not built (will use slow scan)\n")
202
202
+
}
203
203
+
sb.WriteString("\n")
204
204
+
}
205
205
+
206
206
+
if s.config.EnableWebSocket {
207
207
+
sb.WriteString("\nWebSocket Endpoints\n")
208
208
+
sb.WriteString("━━━━━━━━━━━━━━━━━━━\n")
209
209
+
sb.WriteString(" WS /ws Live stream (new operations only)\n")
210
210
+
sb.WriteString(" WS /ws?cursor=0 Stream all from beginning\n")
211
211
+
sb.WriteString(" WS /ws?cursor=N Stream from cursor N\n\n")
212
212
+
sb.WriteString("Cursor Format:\n")
213
213
+
sb.WriteString(" Global record number: (bundleNumber × 10,000) + position\n")
214
214
+
sb.WriteString(" Example: 88410345 = bundle 8841, position 345\n")
215
215
+
sb.WriteString(" Default: starts from latest (skips all historical data)\n")
216
216
+
217
217
+
latestCursor := s.manager.GetCurrentCursor()
218
218
+
bundledOps := len(index.GetBundles()) * types.BUNDLE_SIZE
219
219
+
mempoolOps := latestCursor - bundledOps
220
220
+
221
221
+
if s.config.SyncMode && mempoolOps > 0 {
222
222
+
sb.WriteString(fmt.Sprintf(" Current latest: %d (%d bundled + %d mempool)\n",
223
223
+
latestCursor, bundledOps, mempoolOps))
224
224
+
} else {
225
225
+
sb.WriteString(fmt.Sprintf(" Current latest: %d (%d bundles)\n",
226
226
+
latestCursor, len(index.GetBundles())))
227
227
+
}
228
228
+
}
229
229
+
230
230
+
sb.WriteString("\nExamples\n")
231
231
+
sb.WriteString("━━━━━━━━\n")
232
232
+
sb.WriteString(fmt.Sprintf(" curl %s/bundle/1\n", baseURL))
233
233
+
sb.WriteString(fmt.Sprintf(" curl %s/data/42 -o 000042.jsonl.zst\n", baseURL))
234
234
+
sb.WriteString(fmt.Sprintf(" curl %s/jsonl/1\n", baseURL))
235
235
+
236
236
+
if s.config.EnableWebSocket {
237
237
+
sb.WriteString(fmt.Sprintf(" websocat %s/ws\n", wsURL))
238
238
+
sb.WriteString(fmt.Sprintf(" websocat '%s/ws?cursor=0'\n", wsURL))
239
239
+
}
240
240
+
241
241
+
if s.config.SyncMode {
242
242
+
sb.WriteString(fmt.Sprintf(" curl %s/status\n", baseURL))
243
243
+
sb.WriteString(fmt.Sprintf(" curl %s/mempool\n", baseURL))
244
244
+
}
245
245
+
246
246
+
sb.WriteString("\n────────────────────────────────────────────────────────────────\n")
247
247
+
sb.WriteString("https://tangled.org/@atscan.net/plcbundle\n")
248
248
+
249
249
+
w.Write([]byte(sb.String()))
250
250
+
}
251
251
+
}
252
252
+
253
253
+
func (s *Server) handleIndexJSON() http.HandlerFunc {
254
254
+
return func(w http.ResponseWriter, r *http.Request) {
255
255
+
index := s.manager.GetIndex()
256
256
+
sendJSON(w, 200, index)
257
257
+
}
258
258
+
}
259
259
+
260
260
+
func (s *Server) handleBundle() http.HandlerFunc {
261
261
+
return func(w http.ResponseWriter, r *http.Request) {
262
262
+
bundleNum, err := strconv.Atoi(r.PathValue("number"))
263
263
+
if err != nil {
264
264
+
sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"})
265
265
+
return
266
266
+
}
267
267
+
268
268
+
meta, err := s.manager.GetIndex().GetBundle(bundleNum)
269
269
+
if err != nil {
270
270
+
sendJSON(w, 404, map[string]string{"error": "Bundle not found"})
271
271
+
return
272
272
+
}
273
273
+
274
274
+
sendJSON(w, 200, meta)
275
275
+
}
276
276
+
}
277
277
+
278
278
+
func (s *Server) handleBundleData() http.HandlerFunc {
279
279
+
return func(w http.ResponseWriter, r *http.Request) {
280
280
+
bundleNum, err := strconv.Atoi(r.PathValue("number"))
281
281
+
if err != nil {
282
282
+
sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"})
283
283
+
return
284
284
+
}
285
285
+
286
286
+
reader, err := s.manager.StreamBundleRaw(context.Background(), bundleNum)
287
287
+
if err != nil {
288
288
+
if strings.Contains(err.Error(), "not in index") || strings.Contains(err.Error(), "not found") {
289
289
+
sendJSON(w, 404, map[string]string{"error": "Bundle not found"})
290
290
+
} else {
291
291
+
sendJSON(w, 500, map[string]string{"error": err.Error()})
292
292
+
}
293
293
+
return
294
294
+
}
295
295
+
defer reader.Close()
296
296
+
297
297
+
w.Header().Set("Content-Type", "application/zstd")
298
298
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl.zst", bundleNum))
299
299
+
300
300
+
io.Copy(w, reader)
301
301
+
}
302
302
+
}
303
303
+
304
304
+
func (s *Server) handleBundleJSONL() http.HandlerFunc {
305
305
+
return func(w http.ResponseWriter, r *http.Request) {
306
306
+
bundleNum, err := strconv.Atoi(r.PathValue("number"))
307
307
+
if err != nil {
308
308
+
sendJSON(w, 400, map[string]string{"error": "Invalid bundle number"})
309
309
+
return
310
310
+
}
311
311
+
312
312
+
reader, err := s.manager.StreamBundleDecompressed(context.Background(), bundleNum)
313
313
+
if err != nil {
314
314
+
if strings.Contains(err.Error(), "not in index") || strings.Contains(err.Error(), "not found") {
315
315
+
sendJSON(w, 404, map[string]string{"error": "Bundle not found"})
316
316
+
} else {
317
317
+
sendJSON(w, 500, map[string]string{"error": err.Error()})
318
318
+
}
319
319
+
return
320
320
+
}
321
321
+
defer reader.Close()
322
322
+
323
323
+
w.Header().Set("Content-Type", "application/x-ndjson")
324
324
+
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%06d.jsonl", bundleNum))
325
325
+
326
326
+
io.Copy(w, reader)
327
327
+
}
328
328
+
}
329
329
+
330
330
+
func (s *Server) handleStatus() http.HandlerFunc {
331
331
+
return func(w http.ResponseWriter, r *http.Request) {
332
332
+
index := s.manager.GetIndex()
333
333
+
indexStats := index.GetStats()
334
334
+
335
335
+
response := StatusResponse{
336
336
+
Server: ServerStatus{
337
337
+
Version: s.config.Version,
338
338
+
UptimeSeconds: int(time.Since(s.startTime).Seconds()),
339
339
+
SyncMode: s.config.SyncMode,
340
340
+
WebSocketEnabled: s.config.EnableWebSocket,
341
341
+
Origin: s.manager.GetPLCOrigin(),
342
342
+
},
343
343
+
Bundles: BundleStatus{
344
344
+
Count: indexStats["bundle_count"].(int),
345
345
+
TotalSize: indexStats["total_size"].(int64),
346
346
+
UncompressedSize: indexStats["total_uncompressed_size"].(int64),
347
347
+
UpdatedAt: indexStats["updated_at"].(time.Time),
348
348
+
},
349
349
+
}
350
350
+
351
351
+
if s.config.SyncMode && s.config.SyncInterval > 0 {
352
352
+
response.Server.SyncIntervalSeconds = int(s.config.SyncInterval.Seconds())
353
353
+
}
354
354
+
355
355
+
if bundleCount := response.Bundles.Count; bundleCount > 0 {
356
356
+
firstBundle := indexStats["first_bundle"].(int)
357
357
+
lastBundle := indexStats["last_bundle"].(int)
358
358
+
359
359
+
response.Bundles.FirstBundle = firstBundle
360
360
+
response.Bundles.LastBundle = lastBundle
361
361
+
response.Bundles.StartTime = indexStats["start_time"].(time.Time)
362
362
+
response.Bundles.EndTime = indexStats["end_time"].(time.Time)
363
363
+
364
364
+
if firstMeta, err := index.GetBundle(firstBundle); err == nil {
365
365
+
response.Bundles.RootHash = firstMeta.Hash
366
366
+
}
367
367
+
368
368
+
if lastMeta, err := index.GetBundle(lastBundle); err == nil {
369
369
+
response.Bundles.HeadHash = lastMeta.Hash
370
370
+
response.Bundles.HeadAgeSeconds = int(time.Since(lastMeta.EndTime).Seconds())
371
371
+
}
372
372
+
373
373
+
if gaps, ok := indexStats["gaps"].(int); ok {
374
374
+
response.Bundles.Gaps = gaps
375
375
+
response.Bundles.HasGaps = gaps > 0
376
376
+
if gaps > 0 {
377
377
+
response.Bundles.GapNumbers = index.FindGaps()
378
378
+
}
379
379
+
}
380
380
+
381
381
+
totalOps := bundleCount * types.BUNDLE_SIZE
382
382
+
response.Bundles.TotalOperations = totalOps
383
383
+
384
384
+
duration := response.Bundles.EndTime.Sub(response.Bundles.StartTime)
385
385
+
if duration.Hours() > 0 {
386
386
+
response.Bundles.AvgOpsPerHour = int(float64(totalOps) / duration.Hours())
387
387
+
}
388
388
+
}
389
389
+
390
390
+
if s.config.SyncMode {
391
391
+
mempoolStats := s.manager.GetMempoolStats()
392
392
+
393
393
+
if count, ok := mempoolStats["count"].(int); ok {
394
394
+
mempool := &MempoolStatus{
395
395
+
Count: count,
396
396
+
TargetBundle: mempoolStats["target_bundle"].(int),
397
397
+
CanCreateBundle: mempoolStats["can_create_bundle"].(bool),
398
398
+
MinTimestamp: mempoolStats["min_timestamp"].(time.Time),
399
399
+
Validated: mempoolStats["validated"].(bool),
400
400
+
ProgressPercent: float64(count) / float64(types.BUNDLE_SIZE) * 100,
401
401
+
BundleSize: types.BUNDLE_SIZE,
402
402
+
OperationsNeeded: types.BUNDLE_SIZE - count,
403
403
+
}
404
404
+
405
405
+
if firstTime, ok := mempoolStats["first_time"].(time.Time); ok {
406
406
+
mempool.FirstTime = firstTime
407
407
+
mempool.TimespanSeconds = int(time.Since(firstTime).Seconds())
408
408
+
}
409
409
+
if lastTime, ok := mempoolStats["last_time"].(time.Time); ok {
410
410
+
mempool.LastTime = lastTime
411
411
+
mempool.LastOpAgeSeconds = int(time.Since(lastTime).Seconds())
412
412
+
}
413
413
+
414
414
+
if count > 100 && count < types.BUNDLE_SIZE {
415
415
+
if !mempool.FirstTime.IsZero() && !mempool.LastTime.IsZero() {
416
416
+
timespan := mempool.LastTime.Sub(mempool.FirstTime)
417
417
+
if timespan.Seconds() > 0 {
418
418
+
opsPerSec := float64(count) / timespan.Seconds()
419
419
+
remaining := types.BUNDLE_SIZE - count
420
420
+
mempool.EtaNextBundleSeconds = int(float64(remaining) / opsPerSec)
421
421
+
}
422
422
+
}
423
423
+
}
424
424
+
425
425
+
response.Mempool = mempool
426
426
+
}
427
427
+
}
428
428
+
429
429
+
sendJSON(w, 200, response)
430
430
+
}
431
431
+
}
432
432
+
433
433
+
func (s *Server) handleMempool() http.HandlerFunc {
434
434
+
return func(w http.ResponseWriter, r *http.Request) {
435
435
+
ops, err := s.manager.GetMempoolOperations()
436
436
+
if err != nil {
437
437
+
sendJSON(w, 500, map[string]string{"error": err.Error()})
438
438
+
return
439
439
+
}
440
440
+
441
441
+
w.Header().Set("Content-Type", "application/x-ndjson")
442
442
+
443
443
+
if len(ops) == 0 {
444
444
+
return
445
445
+
}
446
446
+
447
447
+
for _, op := range ops {
448
448
+
if len(op.RawJSON) > 0 {
449
449
+
w.Write(op.RawJSON)
450
450
+
} else {
451
451
+
data, _ := json.Marshal(op)
452
452
+
w.Write(data)
453
453
+
}
454
454
+
w.Write([]byte("\n"))
455
455
+
}
456
456
+
}
457
457
+
}
458
458
+
459
459
+
func (s *Server) handleDebugMemory() http.HandlerFunc {
460
460
+
return func(w http.ResponseWriter, r *http.Request) {
461
461
+
var m runtime.MemStats
462
462
+
runtime.ReadMemStats(&m)
463
463
+
464
464
+
didStats := s.manager.GetDIDIndexStats()
465
465
+
466
466
+
beforeAlloc := m.Alloc / 1024 / 1024
467
467
+
468
468
+
runtime.GC()
469
469
+
runtime.ReadMemStats(&m)
470
470
+
afterAlloc := m.Alloc / 1024 / 1024
471
471
+
472
472
+
response := fmt.Sprintf(`Memory Stats:
473
473
+
Alloc: %d MB
474
474
+
TotalAlloc: %d MB
475
475
+
Sys: %d MB
476
476
+
NumGC: %d
477
477
+
478
478
+
DID Index:
479
479
+
Cached shards: %d/%d
480
480
+
481
481
+
After GC:
482
482
+
Alloc: %d MB
483
483
+
`,
484
484
+
beforeAlloc,
485
485
+
m.TotalAlloc/1024/1024,
486
486
+
m.Sys/1024/1024,
487
487
+
m.NumGC,
488
488
+
didStats["cached_shards"],
489
489
+
didStats["cache_limit"],
490
490
+
afterAlloc)
491
491
+
492
492
+
w.Header().Set("Content-Type", "text/plain")
493
493
+
w.Write([]byte(response))
494
494
+
}
495
495
+
}
496
496
+
497
497
+
func (s *Server) handleDIDRouting(w http.ResponseWriter, r *http.Request) {
498
498
+
path := strings.TrimPrefix(r.URL.Path, "/")
499
499
+
500
500
+
parts := strings.SplitN(path, "/", 2)
501
501
+
did := parts[0]
502
502
+
503
503
+
if !strings.HasPrefix(did, "did:plc:") {
504
504
+
sendJSON(w, 404, map[string]string{"error": "not found"})
505
505
+
return
506
506
+
}
507
507
+
508
508
+
if len(parts) == 1 {
509
509
+
s.handleDIDDocument(did)(w, r)
510
510
+
} else if parts[1] == "data" {
511
511
+
s.handleDIDData(did)(w, r)
512
512
+
} else if parts[1] == "log/audit" {
513
513
+
s.handleDIDAuditLog(did)(w, r)
514
514
+
} else {
515
515
+
sendJSON(w, 404, map[string]string{"error": "not found"})
516
516
+
}
517
517
+
}
518
518
+
519
519
+
func (s *Server) handleDIDDocument(did string) http.HandlerFunc {
520
520
+
return func(w http.ResponseWriter, r *http.Request) {
521
521
+
op, err := s.manager.GetLatestDIDOperation(context.Background(), did)
522
522
+
if err != nil {
523
523
+
sendJSON(w, 500, map[string]string{"error": err.Error()})
524
524
+
return
525
525
+
}
526
526
+
527
527
+
doc, err := plcclient.ResolveDIDDocument(did, []plcclient.PLCOperation{*op})
528
528
+
if err != nil {
529
529
+
if strings.Contains(err.Error(), "deactivated") {
530
530
+
sendJSON(w, 410, map[string]string{"error": "DID has been deactivated"})
531
531
+
} else {
532
532
+
sendJSON(w, 500, map[string]string{"error": fmt.Sprintf("Resolution failed: %v", err)})
533
533
+
}
534
534
+
return
535
535
+
}
536
536
+
537
537
+
w.Header().Set("Content-Type", "application/did+ld+json")
538
538
+
sendJSON(w, 200, doc)
539
539
+
}
540
540
+
}
541
541
+
542
542
+
func (s *Server) handleDIDData(did string) http.HandlerFunc {
543
543
+
return func(w http.ResponseWriter, r *http.Request) {
544
544
+
if err := plcclient.ValidateDIDFormat(did); err != nil {
545
545
+
sendJSON(w, 400, map[string]string{"error": "Invalid DID format"})
546
546
+
return
547
547
+
}
548
548
+
549
549
+
operations, err := s.manager.GetDIDOperations(context.Background(), did, false)
550
550
+
if err != nil {
551
551
+
sendJSON(w, 500, map[string]string{"error": err.Error()})
552
552
+
return
553
553
+
}
554
554
+
555
555
+
if len(operations) == 0 {
556
556
+
sendJSON(w, 404, map[string]string{"error": "DID not found"})
557
557
+
return
558
558
+
}
559
559
+
560
560
+
state, err := plcclient.BuildDIDState(did, operations)
561
561
+
if err != nil {
562
562
+
if strings.Contains(err.Error(), "deactivated") {
563
563
+
sendJSON(w, 410, map[string]string{"error": "DID has been deactivated"})
564
564
+
} else {
565
565
+
sendJSON(w, 500, map[string]string{"error": err.Error()})
566
566
+
}
567
567
+
return
568
568
+
}
569
569
+
570
570
+
sendJSON(w, 200, state)
571
571
+
}
572
572
+
}
573
573
+
574
574
+
func (s *Server) handleDIDAuditLog(did string) http.HandlerFunc {
575
575
+
return func(w http.ResponseWriter, r *http.Request) {
576
576
+
if err := plcclient.ValidateDIDFormat(did); err != nil {
577
577
+
sendJSON(w, 400, map[string]string{"error": "Invalid DID format"})
578
578
+
return
579
579
+
}
580
580
+
581
581
+
operations, err := s.manager.GetDIDOperations(context.Background(), did, false)
582
582
+
if err != nil {
583
583
+
sendJSON(w, 500, map[string]string{"error": err.Error()})
584
584
+
return
585
585
+
}
586
586
+
587
587
+
if len(operations) == 0 {
588
588
+
sendJSON(w, 404, map[string]string{"error": "DID not found"})
589
589
+
return
590
590
+
}
591
591
+
592
592
+
auditLog := plcclient.FormatAuditLog(operations)
593
593
+
sendJSON(w, 200, auditLog)
594
594
+
}
595
595
+
}
+58
server/helpers.go
···
1
1
+
package server
2
2
+
3
3
+
import (
4
4
+
"fmt"
5
5
+
"net/http"
6
6
+
)
7
7
+
8
8
+
// getScheme determines the HTTP scheme
9
9
+
func getScheme(r *http.Request) string {
10
10
+
if r.TLS != nil {
11
11
+
return "https"
12
12
+
}
13
13
+
14
14
+
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
15
15
+
return proto
16
16
+
}
17
17
+
18
18
+
if r.Header.Get("X-Forwarded-Ssl") == "on" {
19
19
+
return "https"
20
20
+
}
21
21
+
22
22
+
return "http"
23
23
+
}
24
24
+
25
25
+
// getWSScheme determines the WebSocket scheme
26
26
+
func getWSScheme(r *http.Request) string {
27
27
+
if getScheme(r) == "https" {
28
28
+
return "wss"
29
29
+
}
30
30
+
return "ws"
31
31
+
}
32
32
+
33
33
+
// getBaseURL returns the base URL for HTTP
34
34
+
func getBaseURL(r *http.Request) string {
35
35
+
scheme := getScheme(r)
36
36
+
host := r.Host
37
37
+
return fmt.Sprintf("%s://%s", scheme, host)
38
38
+
}
39
39
+
40
40
+
// getWSURL returns the base URL for WebSocket
41
41
+
func getWSURL(r *http.Request) string {
42
42
+
scheme := getWSScheme(r)
43
43
+
host := r.Host
44
44
+
return fmt.Sprintf("%s://%s", scheme, host)
45
45
+
}
46
46
+
47
47
+
// formatNumber formats numbers with thousand separators
48
48
+
func formatNumber(n int) string {
49
49
+
s := fmt.Sprintf("%d", n)
50
50
+
var result []byte
51
51
+
for i, c := range s {
52
52
+
if i > 0 && (len(s)-i)%3 == 0 {
53
53
+
result = append(result, ',')
54
54
+
}
55
55
+
result = append(result, byte(c))
56
56
+
}
57
57
+
return string(result)
58
58
+
}
+52
server/middleware.go
···
1
1
+
package server
2
2
+
3
3
+
import (
4
4
+
"net/http"
5
5
+
6
6
+
"github.com/goccy/go-json"
7
7
+
)
8
8
+
9
9
+
// corsMiddleware adds CORS headers
10
10
+
func corsMiddleware(next http.Handler) http.Handler {
11
11
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
12
12
+
// Skip CORS for WebSocket upgrade requests
13
13
+
if r.Header.Get("Upgrade") == "websocket" {
14
14
+
next.ServeHTTP(w, r)
15
15
+
return
16
16
+
}
17
17
+
18
18
+
// Normal CORS handling
19
19
+
w.Header().Set("Access-Control-Allow-Origin", "*")
20
20
+
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
21
21
+
22
22
+
if requestedHeaders := r.Header.Get("Access-Control-Request-Headers"); requestedHeaders != "" {
23
23
+
w.Header().Set("Access-Control-Allow-Headers", requestedHeaders)
24
24
+
} else {
25
25
+
w.Header().Set("Access-Control-Allow-Headers", "*")
26
26
+
}
27
27
+
28
28
+
w.Header().Set("Access-Control-Max-Age", "86400")
29
29
+
30
30
+
if r.Method == "OPTIONS" {
31
31
+
w.WriteHeader(204)
32
32
+
return
33
33
+
}
34
34
+
35
35
+
next.ServeHTTP(w, r)
36
36
+
})
37
37
+
}
38
38
+
39
39
+
// sendJSON sends a JSON response
40
40
+
func sendJSON(w http.ResponseWriter, statusCode int, data interface{}) {
41
41
+
w.Header().Set("Content-Type", "application/json")
42
42
+
43
43
+
jsonData, err := json.Marshal(data)
44
44
+
if err != nil {
45
45
+
w.WriteHeader(500)
46
46
+
w.Write([]byte(`{"error":"failed to marshal JSON"}`))
47
47
+
return
48
48
+
}
49
49
+
50
50
+
w.WriteHeader(statusCode)
51
51
+
w.Write(jsonData)
52
52
+
}
+108
server/server.go
···
1
1
+
package server
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"net/http"
6
6
+
"time"
7
7
+
8
8
+
"tangled.org/atscan.net/plcbundle/internal/bundle"
9
9
+
)
10
10
+
11
11
+
// Server serves bundle data over HTTP
12
12
+
type Server struct {
13
13
+
manager *bundle.Manager
14
14
+
addr string
15
15
+
config *Config
16
16
+
startTime time.Time
17
17
+
httpServer *http.Server
18
18
+
}
19
19
+
20
20
+
// Config configures the server
21
21
+
type Config struct {
22
22
+
Addr string
23
23
+
SyncMode bool
24
24
+
SyncInterval time.Duration
25
25
+
EnableWebSocket bool
26
26
+
EnableResolver bool
27
27
+
Version string
28
28
+
}
29
29
+
30
30
+
// New creates a new HTTP server
31
31
+
func New(manager *bundle.Manager, config *Config) *Server {
32
32
+
if config.Version == "" {
33
33
+
config.Version = "dev"
34
34
+
}
35
35
+
36
36
+
s := &Server{
37
37
+
manager: manager,
38
38
+
addr: config.Addr,
39
39
+
config: config,
40
40
+
startTime: time.Now(),
41
41
+
}
42
42
+
43
43
+
handler := s.createHandler()
44
44
+
45
45
+
s.httpServer = &http.Server{
46
46
+
Addr: config.Addr,
47
47
+
Handler: handler,
48
48
+
}
49
49
+
50
50
+
return s
51
51
+
}
52
52
+
53
53
+
// ListenAndServe starts the HTTP server
54
54
+
func (s *Server) ListenAndServe() error {
55
55
+
return s.httpServer.ListenAndServe()
56
56
+
}
57
57
+
58
58
+
// Shutdown gracefully shuts down the server
59
59
+
func (s *Server) Shutdown(ctx context.Context) error {
60
60
+
return s.httpServer.Shutdown(ctx)
61
61
+
}
62
62
+
63
63
+
// createHandler creates the HTTP handler with all routes
64
64
+
func (s *Server) createHandler() http.Handler {
65
65
+
mux := http.NewServeMux()
66
66
+
67
67
+
// Specific routes first
68
68
+
mux.HandleFunc("GET /index.json", s.handleIndexJSON())
69
69
+
mux.HandleFunc("GET /bundle/{number}", s.handleBundle())
70
70
+
mux.HandleFunc("GET /data/{number}", s.handleBundleData())
71
71
+
mux.HandleFunc("GET /jsonl/{number}", s.handleBundleJSONL())
72
72
+
mux.HandleFunc("GET /status", s.handleStatus())
73
73
+
mux.HandleFunc("GET /debug/memory", s.handleDebugMemory())
74
74
+
75
75
+
// WebSocket
76
76
+
if s.config.EnableWebSocket {
77
77
+
mux.HandleFunc("GET /ws", s.handleWebSocket())
78
78
+
}
79
79
+
80
80
+
// Sync mode endpoints
81
81
+
if s.config.SyncMode {
82
82
+
mux.HandleFunc("GET /mempool", s.handleMempool())
83
83
+
}
84
84
+
85
85
+
// Root and DID resolver
86
86
+
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
87
87
+
path := r.URL.Path
88
88
+
89
89
+
if path == "/" {
90
90
+
s.handleRoot()(w, r)
91
91
+
return
92
92
+
}
93
93
+
94
94
+
if s.config.EnableResolver {
95
95
+
s.handleDIDRouting(w, r)
96
96
+
return
97
97
+
}
98
98
+
99
99
+
sendJSON(w, 404, map[string]string{"error": "not found"})
100
100
+
})
101
101
+
102
102
+
return corsMiddleware(mux)
103
103
+
}
104
104
+
105
105
+
// GetStartTime returns when the server started
106
106
+
func (s *Server) GetStartTime() time.Time {
107
107
+
return s.startTime
108
108
+
}
+58
server/types.go
···
1
1
+
package server
2
2
+
3
3
+
import "time"
4
4
+
5
5
+
// StatusResponse is the /status endpoint response
6
6
+
type StatusResponse struct {
7
7
+
Bundles BundleStatus `json:"bundles"`
8
8
+
Mempool *MempoolStatus `json:"mempool,omitempty"`
9
9
+
Server ServerStatus `json:"server"`
10
10
+
}
11
11
+
12
12
+
// ServerStatus contains server information
13
13
+
type ServerStatus struct {
14
14
+
Version string `json:"version"`
15
15
+
UptimeSeconds int `json:"uptime_seconds"`
16
16
+
SyncMode bool `json:"sync_mode"`
17
17
+
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
18
18
+
WebSocketEnabled bool `json:"websocket_enabled"`
19
19
+
Origin string `json:"origin,omitempty"`
20
20
+
}
21
21
+
22
22
+
// BundleStatus contains bundle statistics
23
23
+
type BundleStatus struct {
24
24
+
Count int `json:"count"`
25
25
+
FirstBundle int `json:"first_bundle,omitempty"`
26
26
+
LastBundle int `json:"last_bundle,omitempty"`
27
27
+
TotalSize int64 `json:"total_size"`
28
28
+
UncompressedSize int64 `json:"uncompressed_size,omitempty"`
29
29
+
CompressionRatio float64 `json:"compression_ratio,omitempty"`
30
30
+
TotalOperations int `json:"total_operations,omitempty"`
31
31
+
AvgOpsPerHour int `json:"avg_ops_per_hour,omitempty"`
32
32
+
StartTime time.Time `json:"start_time,omitempty"`
33
33
+
EndTime time.Time `json:"end_time,omitempty"`
34
34
+
UpdatedAt time.Time `json:"updated_at"`
35
35
+
HeadAgeSeconds int `json:"head_age_seconds,omitempty"`
36
36
+
RootHash string `json:"root_hash,omitempty"`
37
37
+
HeadHash string `json:"head_hash,omitempty"`
38
38
+
Gaps int `json:"gaps,omitempty"`
39
39
+
HasGaps bool `json:"has_gaps"`
40
40
+
GapNumbers []int `json:"gap_numbers,omitempty"`
41
41
+
}
42
42
+
43
43
+
// MempoolStatus contains mempool statistics
44
44
+
type MempoolStatus struct {
45
45
+
Count int `json:"count"`
46
46
+
TargetBundle int `json:"target_bundle"`
47
47
+
CanCreateBundle bool `json:"can_create_bundle"`
48
48
+
MinTimestamp time.Time `json:"min_timestamp"`
49
49
+
Validated bool `json:"validated"`
50
50
+
ProgressPercent float64 `json:"progress_percent"`
51
51
+
BundleSize int `json:"bundle_size"`
52
52
+
OperationsNeeded int `json:"operations_needed"`
53
53
+
FirstTime time.Time `json:"first_time,omitempty"`
54
54
+
LastTime time.Time `json:"last_time,omitempty"`
55
55
+
TimespanSeconds int `json:"timespan_seconds,omitempty"`
56
56
+
LastOpAgeSeconds int `json:"last_op_age_seconds,omitempty"`
57
57
+
EtaNextBundleSeconds int `json:"eta_next_bundle_seconds,omitempty"`
58
58
+
}
+243
server/websocket.go
···
1
1
+
package server
2
2
+
3
3
+
import (
4
4
+
"bufio"
5
5
+
"context"
6
6
+
"fmt"
7
7
+
"net/http"
8
8
+
"os"
9
9
+
"strconv"
10
10
+
"time"
11
11
+
12
12
+
"github.com/goccy/go-json"
13
13
+
"github.com/gorilla/websocket"
14
14
+
"tangled.org/atscan.net/plcbundle/internal/types"
15
15
+
"tangled.org/atscan.net/plcbundle/plcclient"
16
16
+
)
17
17
+
18
18
+
var upgrader = websocket.Upgrader{
19
19
+
ReadBufferSize: 1024,
20
20
+
WriteBufferSize: 1024,
21
21
+
CheckOrigin: func(r *http.Request) bool {
22
22
+
return true
23
23
+
},
24
24
+
}
25
25
+
26
26
+
func (s *Server) handleWebSocket() http.HandlerFunc {
27
27
+
return func(w http.ResponseWriter, r *http.Request) {
28
28
+
cursorStr := r.URL.Query().Get("cursor")
29
29
+
var cursor int
30
30
+
31
31
+
if cursorStr == "" {
32
32
+
cursor = s.manager.GetCurrentCursor()
33
33
+
} else {
34
34
+
var err error
35
35
+
cursor, err = strconv.Atoi(cursorStr)
36
36
+
if err != nil || cursor < 0 {
37
37
+
http.Error(w, "Invalid cursor: must be non-negative integer", 400)
38
38
+
return
39
39
+
}
40
40
+
}
41
41
+
42
42
+
conn, err := upgrader.Upgrade(w, r, nil)
43
43
+
if err != nil {
44
44
+
fmt.Fprintf(os.Stderr, "WebSocket upgrade failed: %v\n", err)
45
45
+
return
46
46
+
}
47
47
+
defer conn.Close()
48
48
+
49
49
+
conn.SetPongHandler(func(string) error {
50
50
+
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
51
51
+
return nil
52
52
+
})
53
53
+
54
54
+
done := make(chan struct{})
55
55
+
56
56
+
go func() {
57
57
+
defer close(done)
58
58
+
for {
59
59
+
_, _, err := conn.ReadMessage()
60
60
+
if err != nil {
61
61
+
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
62
62
+
fmt.Fprintf(os.Stderr, "WebSocket: client closed connection\n")
63
63
+
}
64
64
+
return
65
65
+
}
66
66
+
}
67
67
+
}()
68
68
+
69
69
+
bgCtx := context.Background()
70
70
+
71
71
+
if err := s.streamLive(bgCtx, conn, cursor, done); err != nil {
72
72
+
fmt.Fprintf(os.Stderr, "WebSocket stream error: %v\n", err)
73
73
+
}
74
74
+
}
75
75
+
}
76
76
+
77
77
+
func (s *Server) streamLive(ctx context.Context, conn *websocket.Conn, startCursor int, done chan struct{}) error {
78
78
+
index := s.manager.GetIndex()
79
79
+
bundles := index.GetBundles()
80
80
+
currentRecord := startCursor
81
81
+
82
82
+
// Stream existing bundles
83
83
+
if len(bundles) > 0 {
84
84
+
startBundleIdx := startCursor / types.BUNDLE_SIZE
85
85
+
startPosition := startCursor % types.BUNDLE_SIZE
86
86
+
87
87
+
if startBundleIdx < len(bundles) {
88
88
+
for i := startBundleIdx; i < len(bundles); i++ {
89
89
+
skipUntil := 0
90
90
+
if i == startBundleIdx {
91
91
+
skipUntil = startPosition
92
92
+
}
93
93
+
94
94
+
newRecordCount, err := s.streamBundle(ctx, conn, bundles[i].BundleNumber, skipUntil, done)
95
95
+
if err != nil {
96
96
+
return err
97
97
+
}
98
98
+
currentRecord += newRecordCount
99
99
+
}
100
100
+
}
101
101
+
}
102
102
+
103
103
+
lastSeenMempoolCount := 0
104
104
+
if err := s.streamMempool(conn, startCursor, len(bundles)*types.BUNDLE_SIZE, ¤tRecord, &lastSeenMempoolCount, done); err != nil {
105
105
+
return err
106
106
+
}
107
107
+
108
108
+
ticker := time.NewTicker(500 * time.Millisecond)
109
109
+
defer ticker.Stop()
110
110
+
111
111
+
lastBundleCount := len(bundles)
112
112
+
113
113
+
for {
114
114
+
select {
115
115
+
case <-done:
116
116
+
return nil
117
117
+
118
118
+
case <-ticker.C:
119
119
+
index = s.manager.GetIndex()
120
120
+
bundles = index.GetBundles()
121
121
+
122
122
+
if len(bundles) > lastBundleCount {
123
123
+
newBundleCount := len(bundles) - lastBundleCount
124
124
+
currentRecord += newBundleCount * types.BUNDLE_SIZE
125
125
+
lastBundleCount = len(bundles)
126
126
+
lastSeenMempoolCount = 0
127
127
+
}
128
128
+
129
129
+
if err := s.streamMempool(conn, startCursor, len(bundles)*types.BUNDLE_SIZE, ¤tRecord, &lastSeenMempoolCount, done); err != nil {
130
130
+
return err
131
131
+
}
132
132
+
133
133
+
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
134
134
+
return err
135
135
+
}
136
136
+
}
137
137
+
}
138
138
+
}
139
139
+
140
140
+
func (s *Server) streamBundle(ctx context.Context, conn *websocket.Conn, bundleNumber int, skipUntil int, done chan struct{}) (int, error) {
141
141
+
reader, err := s.manager.StreamBundleDecompressed(ctx, bundleNumber)
142
142
+
if err != nil {
143
143
+
return 0, nil
144
144
+
}
145
145
+
defer reader.Close()
146
146
+
147
147
+
scanner := bufio.NewScanner(reader)
148
148
+
buf := make([]byte, 0, 64*1024)
149
149
+
scanner.Buffer(buf, 1024*1024)
150
150
+
151
151
+
position := 0
152
152
+
streamed := 0
153
153
+
154
154
+
for scanner.Scan() {
155
155
+
line := scanner.Bytes()
156
156
+
if len(line) == 0 {
157
157
+
continue
158
158
+
}
159
159
+
160
160
+
if position < skipUntil {
161
161
+
position++
162
162
+
continue
163
163
+
}
164
164
+
165
165
+
select {
166
166
+
case <-done:
167
167
+
return streamed, nil
168
168
+
default:
169
169
+
}
170
170
+
171
171
+
if err := conn.WriteMessage(websocket.TextMessage, line); err != nil {
172
172
+
return streamed, err
173
173
+
}
174
174
+
175
175
+
position++
176
176
+
streamed++
177
177
+
178
178
+
if streamed%1000 == 0 {
179
179
+
conn.WriteMessage(websocket.PingMessage, nil)
180
180
+
}
181
181
+
}
182
182
+
183
183
+
if err := scanner.Err(); err != nil {
184
184
+
return streamed, fmt.Errorf("scanner error on bundle %d: %w", bundleNumber, err)
185
185
+
}
186
186
+
187
187
+
return streamed, nil
188
188
+
}
189
189
+
190
190
+
func (s *Server) streamMempool(conn *websocket.Conn, startCursor int, bundleRecordBase int, currentRecord *int, lastSeenCount *int, done chan struct{}) error {
191
191
+
mempoolOps, err := s.manager.GetMempoolOperations()
192
192
+
if err != nil {
193
193
+
return nil
194
194
+
}
195
195
+
196
196
+
if len(mempoolOps) <= *lastSeenCount {
197
197
+
return nil
198
198
+
}
199
199
+
200
200
+
for i := *lastSeenCount; i < len(mempoolOps); i++ {
201
201
+
recordNum := bundleRecordBase + i
202
202
+
if recordNum < startCursor {
203
203
+
continue
204
204
+
}
205
205
+
206
206
+
select {
207
207
+
case <-done:
208
208
+
return nil
209
209
+
default:
210
210
+
}
211
211
+
212
212
+
if err := sendOperation(conn, mempoolOps[i]); err != nil {
213
213
+
return err
214
214
+
}
215
215
+
*currentRecord++
216
216
+
}
217
217
+
218
218
+
*lastSeenCount = len(mempoolOps)
219
219
+
return nil
220
220
+
}
221
221
+
222
222
+
func sendOperation(conn *websocket.Conn, op plcclient.PLCOperation) error {
223
223
+
var data []byte
224
224
+
var err error
225
225
+
226
226
+
if len(op.RawJSON) > 0 {
227
227
+
data = op.RawJSON
228
228
+
} else {
229
229
+
data, err = json.Marshal(op)
230
230
+
if err != nil {
231
231
+
return nil
232
232
+
}
233
233
+
}
234
234
+
235
235
+
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
236
236
+
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
237
237
+
fmt.Fprintf(os.Stderr, "WebSocket write error: %v\n", err)
238
238
+
}
239
239
+
return err
240
240
+
}
241
241
+
242
242
+
return nil
243
243
+
}
+46
types.go
···
1
1
+
package plcbundle
2
2
+
3
3
+
import (
4
4
+
"time"
5
5
+
6
6
+
"tangled.org/atscan.net/plcbundle/plcclient"
7
7
+
)
8
8
+
9
9
+
// Bundle represents a PLC bundle (public version)
10
10
+
type Bundle struct {
11
11
+
BundleNumber int
12
12
+
StartTime time.Time
13
13
+
EndTime time.Time
14
14
+
Operations []plcclient.PLCOperation
15
15
+
DIDCount int
16
16
+
Hash string
17
17
+
CompressedSize int64
18
18
+
UncompressedSize int64
19
19
+
}
20
20
+
21
21
+
// BundleInfo provides metadata about a bundle
22
22
+
type BundleInfo struct {
23
23
+
BundleNumber int `json:"bundle_number"`
24
24
+
StartTime time.Time `json:"start_time"`
25
25
+
EndTime time.Time `json:"end_time"`
26
26
+
OperationCount int `json:"operation_count"`
27
27
+
DIDCount int `json:"did_count"`
28
28
+
Hash string `json:"hash"`
29
29
+
CompressedSize int64 `json:"compressed_size"`
30
30
+
UncompressedSize int64 `json:"uncompressed_size"`
31
31
+
}
32
32
+
33
33
+
// IndexStats provides statistics about the bundle index
34
34
+
type IndexStats struct {
35
35
+
BundleCount int
36
36
+
FirstBundle int
37
37
+
LastBundle int
38
38
+
TotalSize int64
39
39
+
MissingBundles []int
40
40
+
}
41
41
+
42
42
+
// Helper to convert internal bundle to public
43
43
+
func toBundlePublic(b interface{}) *Bundle {
44
44
+
// Implement conversion from internal bundle to public Bundle
45
45
+
return &Bundle{} // placeholder
46
46
+
}