feat: persist passive browser activity batches

This commit is contained in:
2026-07-12 17:29:07 +08:00
parent 39052ffe39
commit bcf49ed32c
10 changed files with 787 additions and 43 deletions
+119 -25
View File
@@ -40,9 +40,11 @@ var newSyncClient = syncsvc.NewClient
var emitFrontendEvent = runtime.EventsEmit
const pluginEventRuntimeName = "verstak:plugin-event"
const activityGlobalKey = "events:global"
const activityWorkspacePrefix = "events:workspace:"
const maxActivityEvents = 250
const activityPluginID = "verstak.activity"
const activityRawDataName = "activity-events"
const maxActivityRawEvents = 10000
const maxActivityRawBytes = 8 * 1024 * 1024
const activityRetention = 60 * 24 * time.Hour
const browserInboxPluginID = "verstak.browser-inbox"
const browserInboxGlobalKey = "captures:global"
const browserInboxLegacyKey = "captures"
@@ -132,6 +134,7 @@ func NewApp(
app.ensureBrowserInboxSubscriptions()
if app.browserReceiver != nil {
app.browserReceiver.SetPersistence(app.browserInboxAvailable, app.recordBrowserCapture)
app.browserReceiver.SetActivityPersistence(app.activityAvailable, app.recordBrowserActivityBatch)
}
app.startFileWatcherForOpenVault()
return app
@@ -205,6 +208,78 @@ func (a *App) browserInboxAvailable() bool {
return true
}
func (a *App) activityAvailable() bool {
if a == nil || a.storage == nil || a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
return false
}
_, err := a.requirePluginAccess(activityPluginID, "storage.namespace")
return err == nil
}
func (a *App) recordBrowserActivityBatch(event events.Event) error {
if !a.activityAvailable() {
return fmt.Errorf("activity storage unavailable")
}
payload := eventPayloadMap(event.Payload)
batchID := firstPayloadText(payload, "batchId")
if batchID == "" {
return fmt.Errorf("batchId is empty")
}
entries, ok := payload["entries"].([]map[string]interface{})
if !ok || len(entries) == 0 {
return fmt.Errorf("activity batch entries are empty")
}
now := time.Now().UTC().Format(time.RFC3339Nano)
receivedAt := event.Timestamp
if receivedAt == "" {
receivedAt = now
}
records := make([]map[string]interface{}, 0, len(entries))
for index, entry := range entries {
hostname := firstPayloadText(entry, "hostname")
endedAt := firstPayloadText(entry, "endedAt")
if hostname == "" || endedAt == "" {
return fmt.Errorf("activity batch entry %d is invalid", index)
}
durationSeconds, _ := entry["durationSeconds"].(int64)
if durationSeconds == 0 {
if number, ok := entry["durationSeconds"].(float64); ok {
durationSeconds = int64(number)
}
}
records = append(records, map[string]interface{}{
"activityId": fmt.Sprintf("browser-domain:%s:%d", batchID, index),
"type": "browser.activity.domain",
"title": hostname,
"summary": fmt.Sprintf("%d min browser activity", durationSeconds/60),
"occurredAt": endedAt,
"receivedAt": receivedAt,
"sourcePluginId": "verstak-browser-extension",
"sourceBatchId": batchID,
"hostname": hostname,
"startedAt": firstPayloadText(entry, "startedAt"),
"endedAt": endedAt,
"durationSeconds": durationSeconds,
"workspaceRootPath": "",
"payload": map[string]interface{}{
"hostname": hostname,
"startedAt": firstPayloadText(entry, "startedAt"),
"endedAt": endedAt,
"durationSeconds": durationSeconds,
},
})
}
_, err := a.storage.AppendPluginDataNDJSON(activityPluginID, activityRawDataName, records, storage.NDJSONRetention{
TimestampField: "occurredAt",
MaxAge: activityRetention,
MaxEntries: maxActivityRawEvents,
MaxBytes: maxActivityRawBytes,
DeduplicateField: "sourceBatchId",
DeduplicateValue: batchID,
})
return err
}
func (a *App) recordBrowserCapture(event events.Event) error {
if !a.browserInboxAvailable() {
return fmt.Errorf("browser inbox unavailable")
@@ -428,28 +503,13 @@ func (a *App) recordActivityProviderEvent(event events.Event) {
}
func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error {
settings, err := a.storage.ReadPluginSettings(pluginID)
if err != nil {
return err
}
key := activityGlobalKey
if workspace, _ := activity["workspaceRootPath"].(string); strings.TrimSpace(workspace) != "" {
key = activityWorkspacePrefix + url.QueryEscape(strings.TrimSpace(workspace))
key = strings.ReplaceAll(key, "+", "%20")
}
eventsList := []interface{}{activity}
if existing, ok := settings[key].([]interface{}); ok {
eventsList = append(eventsList, existing...)
} else if existingMaps, ok := settings[key].([]map[string]interface{}); ok {
for _, item := range existingMaps {
eventsList = append(eventsList, item)
}
}
if len(eventsList) > maxActivityEvents {
eventsList = eventsList[:maxActivityEvents]
}
settings[key] = eventsList
return a.storage.WritePluginSettings(pluginID, settings)
_, err := a.storage.AppendPluginDataNDJSON(pluginID, activityRawDataName, []map[string]interface{}{activity}, storage.NDJSONRetention{
TimestampField: "occurredAt",
MaxAge: activityRetention,
MaxEntries: maxActivityRawEvents,
MaxBytes: maxActivityRawBytes,
})
return err
}
func activityFromEvent(event events.Event) map[string]interface{} {
@@ -1016,6 +1076,40 @@ func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
return data
}
// ReadPluginDataNDJSON reads append-only plugin data without exposing the
// underlying vault path to plugin frontends.
func (a *App) ReadPluginDataNDJSON(pluginID, name string) []map[string]interface{} {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
log.Printf("[api] ReadPluginDataNDJSON(%s, %s): %v", pluginID, name, err)
return []map[string]interface{}{}
}
if a.storage == nil {
return []map[string]interface{}{}
}
data, err := a.storage.ReadPluginDataNDJSON(pluginID, name)
if err != nil {
log.Printf("[api] ReadPluginDataNDJSON(%s, %s): %v", pluginID, name, err)
return []map[string]interface{}{}
}
return data
}
// WritePluginDataNDJSON replaces append-only data after an explicit user
// action, such as clearing activity history.
func (a *App) WritePluginDataNDJSON(pluginID, name string, data []map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
if a.storage == nil {
return "storage not initialized"
}
if err := a.storage.WritePluginDataNDJSON(pluginID, name, data); err != nil {
log.Printf("[api] WritePluginDataNDJSON(%s, %s): %v", pluginID, name, err)
return err.Error()
}
return ""
}
// WritePluginDataJSON writes a named JSON data file for a plugin.
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
+48 -10
View File
@@ -593,21 +593,14 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
settings, err := app.storage.ReadPluginSettings("verstak.activity")
stored, err := app.storage.ReadPluginDataNDJSON("verstak.activity", activityRawDataName)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings["events:workspace:Project"].([]interface{})
if !ok {
t.Fatalf("events:workspace:Project = %#v, want []interface{}", settings["events:workspace:Project"])
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(stored) != 1 {
t.Fatalf("stored %d activity events, want 1", len(stored))
}
activity, ok := stored[0].(map[string]interface{})
if !ok {
t.Fatalf("activity = %#v, want map[string]interface{}", stored[0])
}
activity := stored[0]
if activity["type"] != "file.changed" {
t.Fatalf("activity type = %#v, want file.changed", activity["type"])
}
@@ -619,6 +612,51 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
}
}
func TestBrowserActivityBatchPersistsWithoutMountedViewAndDeduplicates(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
app := &App{
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: activityPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
event := events.Event{
Name: "browser.activity.batch",
Timestamp: "2026-07-12T10:05:01Z",
Payload: map[string]interface{}{
"batchId": "batch-activity-1",
"entries": []map[string]interface{}{{
"hostname": "example.com", "startedAt": "2026-07-12T10:00:00Z", "endedAt": "2026-07-12T10:05:00Z", "durationSeconds": int64(300),
}},
},
}
if err := app.recordBrowserActivityBatch(event); err != nil {
t.Fatalf("recordBrowserActivityBatch: %v", err)
}
if err := app.recordBrowserActivityBatch(event); err != nil {
t.Fatalf("recordBrowserActivityBatch retry: %v", err)
}
records, err := app.storage.ReadPluginDataNDJSON(activityPluginID, activityRawDataName)
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 1 {
t.Fatalf("records = %+v, want one idempotent domain activity", records)
}
if records[0]["type"] != "browser.activity.domain" || records[0]["hostname"] != "example.com" {
t.Fatalf("record = %+v, want domain-only activity", records[0])
}
if _, ok := records[0]["url"]; ok {
t.Fatalf("record must not contain URL: %+v", records[0])
}
}
func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {