Compare commits
7 Commits
477b372110
...
5a249dcf24
| Author | SHA1 | Date |
|---|---|---|
|
|
5a249dcf24 | |
|
|
6b556c59d9 | |
|
|
fca9fff4b4 | |
|
|
bcf49ed32c | |
|
|
39052ffe39 | |
|
|
e1a6b2af64 | |
|
|
5280d245ee |
|
|
@ -54,6 +54,7 @@
|
|||
const name = workspaceName(workspace);
|
||||
return {
|
||||
id: name,
|
||||
workspaceId: workspace?.id || workspace?.workspaceId || '',
|
||||
type: workspace?.type || 'space',
|
||||
title: workspace?.title || name,
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -262,6 +262,26 @@ export function createPluginAPI(pluginId) {
|
|||
return data || {};
|
||||
});
|
||||
},
|
||||
readNDJSON: function(name) {
|
||||
assertActive('storage.data.readNDJSON(' + name + ')');
|
||||
if (!name) {
|
||||
throw new Error('storage.data.readNDJSON requires a name');
|
||||
}
|
||||
return callBackend(pluginId, 'storage.data.readNDJSON(' + name + ')', function() {
|
||||
return App.ReadPluginDataNDJSON(pluginId, name);
|
||||
}).then(function(records) {
|
||||
return Array.isArray(records) ? records : [];
|
||||
});
|
||||
},
|
||||
writeNDJSON: function(name, records) {
|
||||
assertActive('storage.data.writeNDJSON(' + name + ')');
|
||||
if (!name) {
|
||||
throw new Error('storage.data.writeNDJSON requires a name');
|
||||
}
|
||||
return callBackendErrorString(pluginId, 'storage.data.writeNDJSON(' + name + ')', function() {
|
||||
return App.WritePluginDataNDJSON(pluginId, name, Array.isArray(records) ? records : []);
|
||||
});
|
||||
},
|
||||
write: function(name, data) {
|
||||
assertActive('storage.data.write(' + name + ')');
|
||||
if (!name) {
|
||||
|
|
@ -409,6 +429,12 @@ export function createPluginAPI(pluginId) {
|
|||
return App.OpenVaultPathExternal(pluginId, relativePath);
|
||||
});
|
||||
},
|
||||
openURL: function(url) {
|
||||
assertActive('files.openURL');
|
||||
return callBackendErrorString(pluginId, 'files.openURL', function() {
|
||||
return App.OpenExternalURL(pluginId, String(url == null ? '' : url));
|
||||
});
|
||||
},
|
||||
showInFolder: function(relativePath) {
|
||||
assertActive('files.showInFolder(' + relativePath + ')');
|
||||
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
|
||||
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
|
||||
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
|
||||
$: workspaceId = selectedWorkspace?.workspaceId || '';
|
||||
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
|
||||
$: workspaceType = selectedWorkspace?.type || 'workspace';
|
||||
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
|
||||
|
|
@ -235,7 +236,7 @@
|
|||
<PluginBundleHost
|
||||
pluginId={activeTool.pluginId}
|
||||
componentId={activeTool.component}
|
||||
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, toolRequest: activeToolRequest }}
|
||||
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, workspaceId, toolRequest: activeToolRequest }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -3341,11 +3341,20 @@
|
|||
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
|
||||
return Promise.resolve([Object.assign({}, data), '']);
|
||||
},
|
||||
ReadPluginDataNDJSON: function (pluginId, name) {
|
||||
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || [];
|
||||
return Promise.resolve([Array.isArray(data) ? data.slice() : [], '']);
|
||||
},
|
||||
WritePluginDataJSON: function (pluginId, name, data) {
|
||||
pluginData[pluginId] = pluginData[pluginId] || {};
|
||||
pluginData[pluginId][name] = Object.assign({}, data || {});
|
||||
return Promise.resolve('');
|
||||
},
|
||||
WritePluginDataNDJSON: function (pluginId, name, records) {
|
||||
pluginData[pluginId] = pluginData[pluginId] || {};
|
||||
pluginData[pluginId][name] = Array.isArray(records) ? records.slice() : [];
|
||||
return Promise.resolve('');
|
||||
},
|
||||
OpenWorkbenchResource: function (pluginId, request) {
|
||||
return openWorkbenchResource(pluginId, request || {}, '');
|
||||
},
|
||||
|
|
@ -3678,6 +3687,13 @@
|
|||
window.__wailsMockExternalOpens = externalOpens.slice();
|
||||
return Promise.resolve('');
|
||||
},
|
||||
OpenExternalURL: function (pluginId, rawURL) {
|
||||
var err = requirePluginPermission(pluginId, 'files.openExternal');
|
||||
if (err) return Promise.resolve(err);
|
||||
externalOpens.push({ action: 'url', path: String(rawURL || '') });
|
||||
window.__wailsMockExternalOpens = externalOpens.slice();
|
||||
return Promise.resolve('');
|
||||
},
|
||||
ShowVaultPathInFolder: function (pluginId, relativePath) {
|
||||
var err = requirePluginPermission(pluginId, 'files.openExternal');
|
||||
if (err) return Promise.resolve(err);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|stri
|
|||
|
||||
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
|
||||
|
||||
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
|
||||
|
||||
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
|
||||
|
||||
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
|
||||
|
|
@ -84,6 +86,8 @@ export function OpenVault(arg1:string):Promise<void>;
|
|||
|
||||
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
|
||||
|
||||
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
|
||||
|
|
@ -122,6 +126,8 @@ export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, a
|
|||
|
||||
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||
|
||||
export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
|
||||
|
||||
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
|
||||
|
|
@ -136,12 +142,18 @@ export function ReloadPlugins():Promise<number|string>;
|
|||
|
||||
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
|
||||
|
||||
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
|
||||
|
||||
export function SelectDirectory():Promise<string>;
|
||||
|
||||
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
|
||||
|
||||
export function SelectVaultForOpen():Promise<string>;
|
||||
|
||||
export function SetCurrentVault(arg1:string):Promise<string>;
|
||||
|
|
@ -168,6 +180,8 @@ export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
|
|||
|
||||
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
|
||||
|
||||
export function WritePluginDataNDJSON(arg1:string,arg2:string,arg3:Array<Record<string, any>>):Promise<string>;
|
||||
|
||||
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
|
||||
|
||||
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,10 @@ export function ListWorkspaces() {
|
|||
return window['go']['api']['App']['ListWorkspaces']();
|
||||
}
|
||||
|
||||
export function ListWorkspaceIdentities() {
|
||||
return window['go']['api']['App']['ListWorkspaceIdentities']();
|
||||
}
|
||||
|
||||
export function ListWorkspaceTemplates() {
|
||||
return window['go']['api']['App']['ListWorkspaceTemplates']();
|
||||
}
|
||||
|
|
@ -154,6 +158,10 @@ export function OpenVaultPathExternal(arg1, arg2) {
|
|||
return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenExternalURL(arg1, arg2) {
|
||||
return window['go']['api']['App']['OpenExternalURL'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function OpenWorkbenchResource(arg1, arg2) {
|
||||
return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2);
|
||||
}
|
||||
|
|
@ -230,6 +238,10 @@ export function ReadPluginDataJSON(arg1, arg2) {
|
|||
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ReadPluginDataNDJSON(arg1, arg2) {
|
||||
return window['go']['api']['App']['ReadPluginDataNDJSON'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ReadPluginSetting(arg1, arg2) {
|
||||
return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2);
|
||||
}
|
||||
|
|
@ -258,6 +270,10 @@ export function RenameWorkspace(arg1, arg2) {
|
|||
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RepairWorkspaceIdentity(arg1, arg2) {
|
||||
return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function RenameWorkspaceNode(arg1, arg2) {
|
||||
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
|
||||
}
|
||||
|
|
@ -266,10 +282,18 @@ export function RestoreVaultTrash(arg1, arg2, arg3) {
|
|||
return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function RestoreWorkspaceTrash(arg1, arg2) {
|
||||
return window['go']['api']['App']['RestoreWorkspaceTrash'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SelectDirectory() {
|
||||
return window['go']['api']['App']['SelectDirectory']();
|
||||
}
|
||||
|
||||
export function PurgeWorkspaceTrash(arg1) {
|
||||
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
|
||||
}
|
||||
|
||||
export function SelectVaultForOpen() {
|
||||
return window['go']['api']['App']['SelectVaultForOpen']();
|
||||
}
|
||||
|
|
@ -322,6 +346,10 @@ export function WritePluginDataJSON(arg1, arg2, arg3) {
|
|||
return window['go']['api']['App']['WritePluginDataJSON'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function WritePluginDataNDJSON(arg1, arg2, arg3) {
|
||||
return window['go']['api']['App']['WritePluginDataNDJSON'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function WritePluginSetting(arg1, arg2, arg3) {
|
||||
return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1272,6 +1272,7 @@ export namespace workspace {
|
|||
}
|
||||
}
|
||||
export class Metadata {
|
||||
workspaceId?: string;
|
||||
workspaceName: string;
|
||||
createdFromTemplate?: TemplateSnapshot;
|
||||
features?: Record<string, boolean>;
|
||||
|
|
@ -1285,6 +1286,7 @@ export namespace workspace {
|
|||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.workspaceName = source["workspaceName"];
|
||||
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
|
||||
this.features = source["features"];
|
||||
|
|
@ -1327,6 +1329,7 @@ export namespace workspace {
|
|||
}
|
||||
|
||||
export class TrashResult {
|
||||
workspaceId: string;
|
||||
originalPath: string;
|
||||
trashPath: string;
|
||||
trashId: string;
|
||||
|
|
@ -1338,6 +1341,7 @@ export namespace workspace {
|
|||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.originalPath = source["originalPath"];
|
||||
this.trashPath = source["trashPath"];
|
||||
this.trashId = source["trashId"];
|
||||
|
|
@ -1345,6 +1349,7 @@ export namespace workspace {
|
|||
}
|
||||
}
|
||||
export class Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
|
||||
|
|
@ -1354,9 +1359,27 @@ export namespace workspace {
|
|||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.rootPath = source["rootPath"];
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceIdentity {
|
||||
workspaceId: string;
|
||||
rootPath: string;
|
||||
state: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WorkspaceIdentity(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.rootPath = source["rootPath"];
|
||||
this.state = source["state"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -5,6 +5,7 @@ go 1.24.4
|
|||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/net v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -32,7 +33,6 @@ require (
|
|||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,9 +40,13 @@ 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 activitySessionHandlingKey = "activity-session-handling-v2"
|
||||
const activitySessionHandledEvent = "activity.session.handled"
|
||||
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"
|
||||
|
|
@ -52,6 +56,8 @@ const maxBrowserInboxCaptures = 100
|
|||
const workspaceCreatedEventName = "workspace.created"
|
||||
const workspaceRenamedEventName = "workspace.renamed"
|
||||
const workspaceTrashedEventName = "workspace.trashed"
|
||||
const workspaceRestoredEventName = "workspace.restored"
|
||||
const workspacePurgedEventName = "workspace.purged"
|
||||
const workspaceSelectedEventName = "workspace.selected"
|
||||
|
||||
// App is the main application struct exposed to the Wails frontend.
|
||||
|
|
@ -130,6 +136,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
|
||||
|
|
@ -183,15 +190,21 @@ func (a *App) ensureBrowserInboxSubscriptions() {
|
|||
if a.browserInboxEvents == nil {
|
||||
a.browserInboxEvents = make(map[string]bool)
|
||||
}
|
||||
for _, eventName := range []string{browserInboxMutationEvent} {
|
||||
for _, eventName := range []string{browserInboxMutationEvent, workspaceRenamedEventName, workspaceTrashedEventName, workspaceRestoredEventName, workspacePurgedEventName} {
|
||||
if a.browserInboxEvents[eventName] {
|
||||
continue
|
||||
}
|
||||
a.browserInboxEvents[eventName] = true
|
||||
a.eventBus.Subscribe(eventName, func(event events.Event) {
|
||||
if event.Name == browserInboxMutationEvent {
|
||||
if err := a.mutateBrowserInboxCapture(event); err != nil {
|
||||
log.Printf("[api] browser inbox mutation failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := a.updateBrowserInboxWorkspaceLifecycle(event); err != nil {
|
||||
log.Printf("[api] browser inbox workspace lifecycle failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -203,6 +216,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")
|
||||
|
|
@ -218,6 +303,10 @@ func (a *App) recordBrowserCapture(event events.Event) error {
|
|||
if firstPayloadText(capture, "capturedAt") == "" {
|
||||
capture["capturedAt"] = event.Timestamp
|
||||
}
|
||||
if firstPayloadText(capture, "globalState") == "" {
|
||||
capture["globalState"] = "inbox"
|
||||
}
|
||||
a.annotateBrowserCaptureWorkspace(capture)
|
||||
capture["receivedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
|
||||
for _, stored := range captures {
|
||||
|
|
@ -238,7 +327,7 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
|
|||
}
|
||||
action := firstPayloadText(payload, "action")
|
||||
switch action {
|
||||
case "migrate", "assign", "delete", "processed":
|
||||
case "migrate", "assign", "archive", "restore", "delete", "processed":
|
||||
default:
|
||||
return fmt.Errorf("unsupported browser inbox mutation %q", action)
|
||||
}
|
||||
|
|
@ -268,10 +357,17 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
|
|||
switch action {
|
||||
case "delete":
|
||||
continue
|
||||
case "archive":
|
||||
capture["globalState"] = "archived"
|
||||
case "restore":
|
||||
capture["globalState"] = "inbox"
|
||||
case "assign":
|
||||
workspaceRoot := firstPayloadText(payload, "workspaceRootPath")
|
||||
capture["workspaceRootPath"] = workspaceRoot
|
||||
capture["workspaceName"] = workspaceRoot
|
||||
delete(capture, "workspaceId")
|
||||
delete(capture, "workspaceTrashId")
|
||||
a.annotateBrowserCaptureWorkspace(capture)
|
||||
case "processed":
|
||||
capture["processed"], _ = payload["processed"].(bool)
|
||||
}
|
||||
|
|
@ -281,6 +377,67 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
|
|||
})
|
||||
}
|
||||
|
||||
func (a *App) annotateBrowserCaptureWorkspace(capture map[string]interface{}) {
|
||||
if capture == nil {
|
||||
return
|
||||
}
|
||||
workspaceRoot := firstPayloadText(capture, "workspaceRootPath")
|
||||
if workspaceRoot == "" {
|
||||
capture["workspaceState"] = "unassigned"
|
||||
delete(capture, "workspaceId")
|
||||
delete(capture, "workspaceTrashId")
|
||||
return
|
||||
}
|
||||
if firstPayloadText(capture, "workspaceId") != "" {
|
||||
if firstPayloadText(capture, "workspaceState") == "" {
|
||||
capture["workspaceState"] = "active"
|
||||
}
|
||||
return
|
||||
}
|
||||
if a.workspace == nil {
|
||||
capture["workspaceState"] = "unavailable"
|
||||
return
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot)
|
||||
if err != nil {
|
||||
capture["workspaceState"] = "unavailable"
|
||||
return
|
||||
}
|
||||
capture["workspaceId"] = identity.WorkspaceID
|
||||
capture["workspaceRootPath"] = identity.RootPath
|
||||
capture["workspaceName"] = identity.RootPath
|
||||
capture["workspaceState"] = identity.State
|
||||
}
|
||||
|
||||
func (a *App) updateBrowserInboxWorkspaceLifecycle(event events.Event) error {
|
||||
payload := eventPayloadMap(event.Payload)
|
||||
workspaceID := firstPayloadText(payload, "workspaceId")
|
||||
if workspaceID == "" {
|
||||
return nil
|
||||
}
|
||||
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
|
||||
for _, capture := range captures {
|
||||
if firstPayloadText(capture, "workspaceId") != workspaceID {
|
||||
continue
|
||||
}
|
||||
switch event.Name {
|
||||
case workspaceRenamedEventName, workspaceRestoredEventName:
|
||||
capture["workspaceRootPath"] = firstPayloadText(payload, "workspaceRootPath")
|
||||
capture["workspaceName"] = firstPayloadText(payload, "workspaceName", "workspaceRootPath")
|
||||
capture["workspaceState"] = "active"
|
||||
delete(capture, "workspaceTrashId")
|
||||
case workspaceTrashedEventName:
|
||||
capture["workspaceState"] = "trashed"
|
||||
capture["workspaceTrashId"] = firstPayloadText(payload, "trashId")
|
||||
case workspacePurgedEventName:
|
||||
capture["workspaceState"] = "orphaned"
|
||||
delete(capture, "workspaceTrashId")
|
||||
}
|
||||
}
|
||||
return captures
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) updateBrowserInboxCaptures(update func([]map[string]interface{}) []map[string]interface{}) error {
|
||||
if !a.browserInboxAvailable() {
|
||||
return fmt.Errorf("browser inbox unavailable")
|
||||
|
|
@ -334,6 +491,9 @@ func browserInboxCaptures(settings map[string]interface{}) ([]map[string]interfa
|
|||
continue
|
||||
}
|
||||
seen[captureID] = true
|
||||
if firstPayloadText(capture, "globalState") == "" {
|
||||
capture["globalState"] = "inbox"
|
||||
}
|
||||
if firstPayloadText(capture, "workspaceRootPath") == "" && workspaceRoot != "" {
|
||||
capture["workspaceRootPath"] = workspaceRoot
|
||||
capture["workspaceName"] = workspaceRoot
|
||||
|
|
@ -406,6 +566,46 @@ func (a *App) ensureActivityProviderSubscriptions() {
|
|||
})
|
||||
}
|
||||
}
|
||||
if !a.activityEvents[activitySessionHandledEvent] {
|
||||
a.activityEvents[activitySessionHandledEvent] = true
|
||||
a.eventBus.Subscribe(activitySessionHandledEvent, func(event events.Event) {
|
||||
if err := a.recordActivitySessionHandled(event); err != nil {
|
||||
log.Printf("[api] activity session handling update failed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordActivitySessionHandled(event events.Event) error {
|
||||
if !a.activityAvailable() {
|
||||
return fmt.Errorf("activity storage unavailable")
|
||||
}
|
||||
payload := eventPayloadMap(event.Payload)
|
||||
if firstPayloadText(payload, "pluginId") != "verstak.journal" {
|
||||
return fmt.Errorf("activity session handling source is not authorized")
|
||||
}
|
||||
sessionID := firstPayloadText(payload, "sessionId")
|
||||
handledThrough := firstPayloadText(payload, "handledThrough")
|
||||
status := firstPayloadText(payload, "status")
|
||||
if sessionID == "" || handledThrough == "" || (status != "accepted" && status != "dismissed") {
|
||||
return fmt.Errorf("activity session handling payload is invalid")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, handledThrough); err != nil {
|
||||
return fmt.Errorf("activity session handledThrough is invalid")
|
||||
}
|
||||
return a.storage.UpdatePluginSettings(activityPluginID, func(settings map[string]interface{}) error {
|
||||
handled, _ := settings[activitySessionHandlingKey].(map[string]interface{})
|
||||
if handled == nil {
|
||||
handled = make(map[string]interface{})
|
||||
}
|
||||
handled[sessionID] = map[string]interface{}{
|
||||
"status": status,
|
||||
"handledThrough": handledThrough,
|
||||
"handledAt": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
settings[activitySessionHandlingKey] = handled
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) recordActivityProviderEvent(event events.Event) {
|
||||
|
|
@ -419,36 +619,21 @@ func (a *App) recordActivityProviderEvent(event events.Event) {
|
|||
if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := a.appendActivityEvent(provider.PluginID, activityFromEvent(event)); err != nil {
|
||||
if err := a.appendActivityEvent(provider.PluginID, a.activityFromEvent(event)); err != nil {
|
||||
log.Printf("[api] activity provider %s failed to record %s: %v", provider.PluginID, event.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error {
|
||||
settings, err := a.storage.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
_, err := a.storage.AppendPluginDataNDJSON(pluginID, activityRawDataName, []map[string]interface{}{activity}, storage.NDJSONRetention{
|
||||
TimestampField: "occurredAt",
|
||||
MaxAge: activityRetention,
|
||||
MaxEntries: maxActivityRawEvents,
|
||||
MaxBytes: maxActivityRawBytes,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
func activityFromEvent(event events.Event) map[string]interface{} {
|
||||
payload := eventPayloadMap(event.Payload)
|
||||
|
|
@ -479,6 +664,24 @@ func activityFromEvent(event events.Event) map[string]interface{} {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *App) activityFromEvent(event events.Event) map[string]interface{} {
|
||||
activity := activityFromEvent(event)
|
||||
workspaceRoot := firstPayloadText(activity, "workspaceRootPath")
|
||||
if workspaceRoot == "" || a == nil || a.workspace == nil {
|
||||
activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"}
|
||||
return activity
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot)
|
||||
if err != nil {
|
||||
activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"}
|
||||
return activity
|
||||
}
|
||||
activity["workspaceId"] = identity.WorkspaceID
|
||||
activity["workspaceRootPath"] = identity.RootPath
|
||||
activity["sessionScope"] = map[string]interface{}{"kind": "workspace", "workspaceId": identity.WorkspaceID}
|
||||
return activity
|
||||
}
|
||||
|
||||
func eventPayloadMap(payload interface{}) map[string]interface{} {
|
||||
switch value := payload.(type) {
|
||||
case map[string]interface{}:
|
||||
|
|
@ -1014,6 +1217,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 {
|
||||
|
|
@ -1321,6 +1558,22 @@ func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// OpenExternalURL opens an HTTP(S) URL through the platform browser opener.
|
||||
// This deliberately bypasses OS file associations for InternetShortcut files.
|
||||
func (a *App) OpenExternalURL(pluginID, rawURL string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "invalid HTTP(S) URL"
|
||||
}
|
||||
if err := a.externalOpenService().OpenPath(parsed.String()); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ShowVaultPathInFolder reveals a vault-relative file or folder in the OS file manager.
|
||||
func (a *App) ShowVaultPathInFolder(pluginID, relativePath string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
||||
|
|
@ -1950,6 +2203,29 @@ func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) {
|
|||
return a.workspace.ListWorkspaceTemplates(), ""
|
||||
}
|
||||
|
||||
// ListWorkspaceIdentities returns durable workspace identities for relation-aware plugins.
|
||||
func (a *App) ListWorkspaceIdentities() ([]workspace.WorkspaceIdentity, string) {
|
||||
if a.workspace == nil {
|
||||
return nil, "workspace not initialized"
|
||||
}
|
||||
identities, err := a.workspace.ListWorkspaceIdentities()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return identities, ""
|
||||
}
|
||||
|
||||
// RepairWorkspaceIdentity resolves a duplicated workspace marker without moving relations.
|
||||
func (a *App) RepairWorkspaceIdentity(keepName, regenerateName string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
if err := a.workspace.RepairWorkspaceIdentity(keepName, regenerateName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateWorkspace creates a top-level physical workspace folder.
|
||||
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
|
|
@ -1961,6 +2237,7 @@ func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, str
|
|||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{
|
||||
"operation": "create",
|
||||
"workspaceId": ws.ID,
|
||||
"workspaceRootPath": ws.RootPath,
|
||||
"workspaceName": ws.Name,
|
||||
"templateId": templateID,
|
||||
|
|
@ -1976,8 +2253,13 @@ func (a *App) RenameWorkspace(oldName, newName string) string {
|
|||
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(newName)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{
|
||||
"operation": "rename",
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"workspaceRootPath": newName,
|
||||
"workspaceName": newName,
|
||||
"previousWorkspaceRootPath": oldName,
|
||||
|
|
@ -1997,6 +2279,7 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
|||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{
|
||||
"operation": "trash",
|
||||
"workspaceId": result.WorkspaceID,
|
||||
"workspaceRootPath": name,
|
||||
"workspaceName": name,
|
||||
"trashId": result.TrashID,
|
||||
|
|
@ -2006,6 +2289,47 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
|||
return result, ""
|
||||
}
|
||||
|
||||
// RestoreWorkspaceTrash restores a trashed workspace and publishes its durable identity.
|
||||
func (a *App) RestoreWorkspaceTrash(trashID, targetName string) (workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
return workspace.Workspace{}, "workspace not initialized"
|
||||
}
|
||||
restored, err := a.workspace.RestoreWorkspaceTrash(trashID, targetName)
|
||||
if err != nil {
|
||||
return workspace.Workspace{}, err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceRestoredEventName, map[string]interface{}{
|
||||
"operation": "restore",
|
||||
"workspaceId": restored.ID,
|
||||
"workspaceRootPath": restored.RootPath,
|
||||
"workspaceName": restored.Name,
|
||||
"trashId": trashID,
|
||||
})
|
||||
return restored, ""
|
||||
}
|
||||
|
||||
// PurgeWorkspaceTrash permanently removes a trashed workspace and publishes its former identity.
|
||||
func (a *App) PurgeWorkspaceTrash(trashID string) string {
|
||||
if a.workspace == nil {
|
||||
return "workspace not initialized"
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceTrashIdentity(trashID)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if err := a.workspace.PurgeWorkspaceTrash(trashID); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspacePurgedEventName, map[string]interface{}{
|
||||
"operation": "purge",
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"workspaceRootPath": identity.RootPath,
|
||||
"workspaceName": identity.RootPath,
|
||||
"trashId": trashID,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetWorkspaceMetadata returns metadata or a generic fallback for a workspace.
|
||||
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
||||
if a.workspace == nil {
|
||||
|
|
@ -2039,7 +2363,13 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
|||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(node.Name)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"error": err.Error()}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": identity.WorkspaceID,
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"name": node.Name,
|
||||
"rootPath": node.RootPath,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -661,6 +699,9 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
|
|||
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
|
||||
"pluginId": browserInboxPluginID, "action": "processed", "captureId": "capture-background", "processed": true,
|
||||
}})
|
||||
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
|
||||
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "capture-background",
|
||||
}})
|
||||
if err := app.recordBrowserCapture(events.Event{
|
||||
Name: "browser.capture.page",
|
||||
Payload: map[string]interface{}{
|
||||
|
|
@ -683,11 +724,154 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("capture = %#v, want map[string]interface{}", stored[0])
|
||||
}
|
||||
if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true {
|
||||
if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true || capture["globalState"] != "archived" {
|
||||
t.Fatalf("stored capture = %#v", capture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserInboxArchiveKeepsAssignmentUntilExplicitPermanentDelete(t *testing.T) {
|
||||
v := vault.NewVault(nil)
|
||||
if err := v.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
bus := events.NewBus()
|
||||
app := &App{
|
||||
eventBus: bus,
|
||||
storage: storage.New(v),
|
||||
vault: v,
|
||||
plugins: []plugin.Plugin{{
|
||||
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
|
||||
Status: plugin.StatusLoaded,
|
||||
Enabled: true,
|
||||
}},
|
||||
}
|
||||
app.ensureBrowserInboxSubscriptions()
|
||||
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
|
||||
"captureId": "archive-1", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
|
||||
}}); err != nil {
|
||||
t.Fatalf("recordBrowserCapture: %v", err)
|
||||
}
|
||||
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
|
||||
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "archive-1",
|
||||
}})
|
||||
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings: %v", err)
|
||||
}
|
||||
captures, _ := settings[browserInboxGlobalKey].([]interface{})
|
||||
if len(captures) != 1 {
|
||||
t.Fatalf("captures after archive = %#v, want retained capture", captures)
|
||||
}
|
||||
archived := captures[0].(map[string]interface{})
|
||||
if archived["workspaceRootPath"] != "Project" || archived["globalState"] != "archived" {
|
||||
t.Fatalf("archived capture = %#v, want preserved assignment", archived)
|
||||
}
|
||||
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
|
||||
"pluginId": browserInboxPluginID, "action": "delete", "captureId": "archive-1", "permanent": true,
|
||||
}})
|
||||
settings, err = app.storage.ReadPluginSettings(browserInboxPluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings after delete: %v", err)
|
||||
}
|
||||
captures, _ = settings[browserInboxGlobalKey].([]interface{})
|
||||
if len(captures) != 0 {
|
||||
t.Fatalf("captures after permanent delete = %#v, want none", captures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserInboxWorkspaceReferenceSurvivesRenameAndTrash(t *testing.T) {
|
||||
v := vault.NewVault(nil)
|
||||
if err := v.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
manager := workspace.NewManager(v.GetVaultPath())
|
||||
created, err := manager.CreateWorkspace("Project", "minimal")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
bus := events.NewBus()
|
||||
app := &App{
|
||||
eventBus: bus,
|
||||
storage: storage.New(v),
|
||||
vault: v,
|
||||
workspace: manager,
|
||||
plugins: []plugin.Plugin{{
|
||||
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
|
||||
Status: plugin.StatusLoaded,
|
||||
Enabled: true,
|
||||
}},
|
||||
}
|
||||
app.ensureBrowserInboxSubscriptions()
|
||||
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
|
||||
"captureId": "workspace-ref", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
|
||||
}}); err != nil {
|
||||
t.Fatalf("recordBrowserCapture: %v", err)
|
||||
}
|
||||
if errStr := app.RenameWorkspace("Project", "Client"); errStr != "" {
|
||||
t.Fatalf("RenameWorkspace: %s", errStr)
|
||||
}
|
||||
if _, errStr := app.TrashWorkspace("Client"); errStr != "" {
|
||||
t.Fatalf("TrashWorkspace: %s", errStr)
|
||||
}
|
||||
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings: %v", err)
|
||||
}
|
||||
capture := settings[browserInboxGlobalKey].([]interface{})[0].(map[string]interface{})
|
||||
if capture["workspaceId"] != created.ID || capture["workspaceRootPath"] != "Client" || capture["workspaceState"] != "trashed" || capture["workspaceTrashId"] == "" {
|
||||
t.Fatalf("workspace reference = %#v", capture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenExternalURLUsesBrowserOpenService(t *testing.T) {
|
||||
app, _ := newFilesTestApp(t, []string{"files.openExternal"})
|
||||
opened := ""
|
||||
app.externalOpen = newTestExternalOpenService(func(path string) error {
|
||||
opened = path
|
||||
return nil
|
||||
})
|
||||
if errStr := app.OpenExternalURL("files.plugin", "https://example.com/path"); errStr != "" {
|
||||
t.Fatalf("OpenExternalURL: %s", errStr)
|
||||
}
|
||||
if opened != "https://example.com/path" {
|
||||
t.Fatalf("opened = %q, want URL", opened)
|
||||
}
|
||||
if errStr := app.OpenExternalURL("files.plugin", "ftp://example.com"); errStr == "" {
|
||||
t.Fatal("OpenExternalURL accepted unsupported URL scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalHandledSessionPersistsActivityWatermark(t *testing.T) {
|
||||
v := vault.NewVault(nil)
|
||||
if err := v.CreateVault(t.TempDir()); err != nil {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
bus := events.NewBus()
|
||||
app := &App{
|
||||
eventBus: bus,
|
||||
storage: storage.New(v),
|
||||
vault: v,
|
||||
contribRegistry: contribution.NewRegistry(),
|
||||
plugins: []plugin.Plugin{
|
||||
{Manifest: plugin.Manifest{ID: activityPluginID, Permissions: []string{"storage.namespace"}}, Status: plugin.StatusLoaded, Enabled: true},
|
||||
{Manifest: plugin.Manifest{ID: "verstak.journal", Permissions: []string{"events.publish"}}, Status: plugin.StatusLoaded, Enabled: true},
|
||||
},
|
||||
}
|
||||
app.ensureActivityProviderSubscriptions()
|
||||
bus.Publish(events.Event{Name: activitySessionHandledEvent, Payload: map[string]interface{}{
|
||||
"pluginId": "verstak.journal", "sessionId": "session-1", "handledThrough": "2026-07-12T10:30:00Z", "status": "accepted",
|
||||
}})
|
||||
settings, err := app.storage.ReadPluginSettings(activityPluginID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings: %v", err)
|
||||
}
|
||||
handled := settings[activitySessionHandlingKey].(map[string]interface{})
|
||||
record := handled["session-1"].(map[string]interface{})
|
||||
if record["status"] != "accepted" || record["handledThrough"] != "2026-07-12T10:30:00Z" {
|
||||
t.Fatalf("handled record = %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) {
|
||||
v := vault.NewVault(nil)
|
||||
app := &App{
|
||||
|
|
@ -1826,6 +2010,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
|
|||
if errStr := app.SetCurrentWorkspace("Project"); errStr != "" {
|
||||
t.Fatalf("SetCurrentWorkspace: %s", errStr)
|
||||
}
|
||||
currentID, ok := app.GetCurrentWorkspace()["workspaceId"].(string)
|
||||
if !ok || currentID == "" {
|
||||
t.Fatalf("GetCurrentWorkspace workspaceId = %#v", app.GetCurrentWorkspace()["workspaceId"])
|
||||
}
|
||||
if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" {
|
||||
t.Fatalf("RenameWorkspace: %s", errStr)
|
||||
}
|
||||
|
|
@ -1836,6 +2024,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
|
|||
if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" {
|
||||
t.Fatalf("workspace.created workspaceRootPath = %#v, want Project", got)
|
||||
}
|
||||
createdID, _ := received["workspace.created"]["workspaceId"].(string)
|
||||
if createdID == "" {
|
||||
t.Fatal("workspace.created workspaceId is empty")
|
||||
}
|
||||
if got := received["workspace.created"]["templateId"]; got != "client-project" {
|
||||
t.Fatalf("workspace.created templateId = %#v, want client-project", got)
|
||||
}
|
||||
|
|
@ -1845,17 +2037,88 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
|
|||
if got := received["workspace.renamed"]["workspaceRootPath"]; got != "Renamed" {
|
||||
t.Fatalf("workspace.renamed workspaceRootPath = %#v, want Renamed", got)
|
||||
}
|
||||
if got := received["workspace.renamed"]["workspaceId"]; got != createdID {
|
||||
t.Fatalf("workspace.renamed workspaceId = %#v, want %q", got, createdID)
|
||||
}
|
||||
if got := received["workspace.renamed"]["previousWorkspaceRootPath"]; got != "Project" {
|
||||
t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got)
|
||||
}
|
||||
if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" {
|
||||
t.Fatalf("workspace.trashed workspaceRootPath = %#v, want Renamed", got)
|
||||
}
|
||||
if got := received["workspace.trashed"]["workspaceId"]; got != createdID {
|
||||
t.Fatalf("workspace.trashed workspaceId = %#v, want %q", got, createdID)
|
||||
}
|
||||
if got := received["workspace.trashed"]["trashPath"]; got == "" {
|
||||
t.Fatalf("workspace.trashed trashPath = %#v, want non-empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceIdentityAPIListsAndRepairsDuplicates(t *testing.T) {
|
||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||
app.workspace = workspace.NewManager(vaultDir)
|
||||
if err := app.workspace.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, errStr := app.CreateWorkspace("Original", "default"); errStr != "" {
|
||||
t.Fatalf("CreateWorkspace: %s", errStr)
|
||||
}
|
||||
identities, errStr := app.ListWorkspaceIdentities()
|
||||
if errStr != "" {
|
||||
t.Fatalf("ListWorkspaceIdentities: %s", errStr)
|
||||
}
|
||||
var original workspace.WorkspaceIdentity
|
||||
for _, identity := range identities {
|
||||
if identity.RootPath == "Original" {
|
||||
original = identity
|
||||
break
|
||||
}
|
||||
}
|
||||
if original.WorkspaceID == "" || original.State != "active" {
|
||||
t.Fatalf("identities = %+v", identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceTrashRestoreAndPurgePublishIdentityLifecycle(t *testing.T) {
|
||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||
app.workspace = workspace.NewManager(vaultDir)
|
||||
app.eventBus = events.NewBus()
|
||||
if err := app.workspace.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
received := map[string]map[string]interface{}{}
|
||||
for _, eventName := range []string{"workspace.restored", "workspace.purged"} {
|
||||
name := eventName
|
||||
app.eventBus.Subscribe(name, func(event events.Event) {
|
||||
received[name], _ = event.Payload.(map[string]interface{})
|
||||
})
|
||||
}
|
||||
if _, errStr := app.CreateWorkspace("Client", "default"); errStr != "" {
|
||||
t.Fatalf("CreateWorkspace: %s", errStr)
|
||||
}
|
||||
trashed, errStr := app.TrashWorkspace("Client")
|
||||
if errStr != "" {
|
||||
t.Fatalf("TrashWorkspace: %s", errStr)
|
||||
}
|
||||
restored, errStr := app.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored")
|
||||
if errStr != "" {
|
||||
t.Fatalf("RestoreWorkspaceTrash: %s", errStr)
|
||||
}
|
||||
if got := received["workspace.restored"]["workspaceId"]; got != restored.ID {
|
||||
t.Fatalf("restored workspaceId = %#v, want %q", got, restored.ID)
|
||||
}
|
||||
trashed, errStr = app.TrashWorkspace("Client-Restored")
|
||||
if errStr != "" {
|
||||
t.Fatalf("TrashWorkspace restored: %s", errStr)
|
||||
}
|
||||
if errStr := app.PurgeWorkspaceTrash(trashed.TrashID); errStr != "" {
|
||||
t.Fatalf("PurgeWorkspaceTrash: %s", errStr)
|
||||
}
|
||||
if got := received["workspace.purged"]["workspaceId"]; got != restored.ID {
|
||||
t.Fatalf("purged workspaceId = %#v, want %q", got, restored.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
|
||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||
app.workspace = workspace.NewManager(vaultDir)
|
||||
|
|
|
|||
|
|
@ -12,18 +12,20 @@ import (
|
|||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||
"github.com/verstak/verstak-desktop/internal/core/hostname"
|
||||
)
|
||||
|
||||
const (
|
||||
capturePath = "/api/browser-inbox/v1/captures"
|
||||
activityBatchPath = "/api/browser-activity/v1/batches"
|
||||
DefaultAddr = "127.0.0.1:47731"
|
||||
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
|
||||
DefaultActivityURL = "http://" + DefaultAddr + activityBatchPath
|
||||
receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||
)
|
||||
|
||||
|
|
@ -43,6 +45,9 @@ const (
|
|||
maxFileTextBytes = 2 * 1024 * 1024
|
||||
maxFileBytes = 8 * 1024 * 1024
|
||||
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
|
||||
maxActivityBodyBytes = 256 * 1024
|
||||
maxActivityEntries = 100
|
||||
maxActivityDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Receiver struct {
|
||||
|
|
@ -59,6 +64,8 @@ type Options struct {
|
|||
ReceiverToken string
|
||||
Available func() bool
|
||||
Persist func(events.Event) error
|
||||
ActivityAvailable func() bool
|
||||
PersistActivity func(events.Event) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
|
|
@ -107,6 +114,23 @@ type CaptureBrowser struct {
|
|||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ActivityBatchPayload contains only domain-level time accounting. It never
|
||||
// accepts or emits page URLs, titles, content, or navigation history.
|
||||
type ActivityBatchPayload struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
BatchID string `json:"batchId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Source string `json:"source"`
|
||||
Entries []ActivityEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type ActivityEntry struct {
|
||||
Hostname string `json:"hostname"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt"`
|
||||
DurationSeconds int64 `json:"durationSeconds"`
|
||||
}
|
||||
|
||||
func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
|
||||
return NewWithOptions(bus, Options{}, providers...)
|
||||
}
|
||||
|
|
@ -141,6 +165,18 @@ func (r *Receiver) SetPersistence(available func() bool, persist func(events.Eve
|
|||
r.options.Persist = persist
|
||||
}
|
||||
|
||||
// SetActivityPersistence configures durable passive browser activity storage.
|
||||
// An acknowledgement is sent only after persist returns successfully.
|
||||
func (r *Receiver) SetActivityPersistence(available func() bool, persist func(events.Event) error) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.optionsMu.Lock()
|
||||
defer r.optionsMu.Unlock()
|
||||
r.options.ActivityAvailable = available
|
||||
r.options.PersistActivity = persist
|
||||
}
|
||||
|
||||
func Start(addr string, receiver *Receiver) (*Server, error) {
|
||||
if receiver == nil {
|
||||
return nil, fmt.Errorf("receiver is required")
|
||||
|
|
@ -181,6 +217,10 @@ func (s *Server) Close() error {
|
|||
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.URL.Path == activityBatchPath {
|
||||
r.serveActivityBatch(w, req)
|
||||
return
|
||||
}
|
||||
if req.URL.Path != capturePath {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
|
|
@ -256,6 +296,59 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
func (r *Receiver) serveActivityBatch(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if err := r.validateReceiverToken(req); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
options := r.currentOptions()
|
||||
if options.ActivityAvailable == nil || !options.ActivityAvailable() || options.PersistActivity == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
defer req.Body.Close()
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxActivityBodyBytes))
|
||||
var batch ActivityBatchPayload
|
||||
if err := decoder.Decode(&batch); err != nil {
|
||||
writeActivityDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeActivityDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
if err := batch.NormalizeAndValidate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
event := events.Event{
|
||||
Name: "browser.activity.batch",
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Payload: batch.EventPayload(),
|
||||
}
|
||||
if err := options.PersistActivity(event); err != nil {
|
||||
log.Printf("[browserreceiver] persist activity %s: %v", batch.BatchID, err)
|
||||
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
|
||||
return
|
||||
}
|
||||
if r.bus != nil {
|
||||
r.bus.Publish(event)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "accepted",
|
||||
"batchId": batch.BatchID,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Receiver) currentOptions() Options {
|
||||
if r == nil {
|
||||
return Options{}
|
||||
|
|
@ -370,6 +463,80 @@ func (p CapturePayload) Validate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// NormalizeAndValidate validates a batch before it is made durable and replaces
|
||||
// each submitted hostname with the shared canonical A-label form.
|
||||
func (p *ActivityBatchPayload) NormalizeAndValidate() error {
|
||||
if p == nil || p.SchemaVersion != 1 {
|
||||
return fmt.Errorf("unsupported schemaVersion")
|
||||
}
|
||||
if strings.TrimSpace(p.BatchID) == "" {
|
||||
return fmt.Errorf("batchId is required")
|
||||
}
|
||||
if err := validateCaptureText(p.BatchID, "batchId", maxCaptureIDBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(p.CreatedAt) == "" {
|
||||
return fmt.Errorf("createdAt is required")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, p.CreatedAt); err != nil {
|
||||
return fmt.Errorf("createdAt is invalid")
|
||||
}
|
||||
if err := validateCaptureText(p.CreatedAt, "createdAt", maxCapturedAtBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(p.Source) == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(p.Entries) == 0 || len(p.Entries) > maxActivityEntries {
|
||||
return fmt.Errorf("entries must contain between 1 and %d items", maxActivityEntries)
|
||||
}
|
||||
for index := range p.Entries {
|
||||
entry := &p.Entries[index]
|
||||
canonical := hostname.NormalizeHostnameV1(entry.Hostname)
|
||||
if canonical == "" {
|
||||
return fmt.Errorf("entries[%d].hostname is invalid", index)
|
||||
}
|
||||
startedAt, err := time.Parse(time.RFC3339, entry.StartedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entries[%d].startedAt is invalid", index)
|
||||
}
|
||||
endedAt, err := time.Parse(time.RFC3339, entry.EndedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entries[%d].endedAt is invalid", index)
|
||||
}
|
||||
interval := endedAt.Sub(startedAt)
|
||||
if interval <= 0 || interval > maxActivityDuration {
|
||||
return fmt.Errorf("entries[%d] interval must be between 1 second and %s", index, maxActivityDuration)
|
||||
}
|
||||
if entry.DurationSeconds <= 0 || entry.DurationSeconds > int64(maxActivityDuration/time.Second) || time.Duration(entry.DurationSeconds)*time.Second > interval {
|
||||
return fmt.Errorf("entries[%d].durationSeconds is invalid", index)
|
||||
}
|
||||
entry.Hostname = canonical
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p ActivityBatchPayload) EventPayload() map[string]interface{} {
|
||||
entries := make([]map[string]interface{}, 0, len(p.Entries))
|
||||
for _, entry := range p.Entries {
|
||||
entries = append(entries, map[string]interface{}{
|
||||
"hostname": entry.Hostname,
|
||||
"startedAt": entry.StartedAt,
|
||||
"endedAt": entry.EndedAt,
|
||||
"durationSeconds": entry.DurationSeconds,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"batchId": strings.TrimSpace(p.BatchID),
|
||||
"createdAt": strings.TrimSpace(p.CreatedAt),
|
||||
"source": strings.TrimSpace(p.Source),
|
||||
"entries": entries,
|
||||
}
|
||||
}
|
||||
|
||||
func (p CapturePayload) validateFile() error {
|
||||
if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
|
||||
return fmt.Errorf("file.name is required")
|
||||
|
|
@ -450,10 +617,19 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
|
|||
}
|
||||
|
||||
func captureDomain(rawURL, fallback string) string {
|
||||
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
if normalized := hostname.NormalizeURLHostnameV1(rawURL); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
return hostname.NormalizeHostnameV1(fallback)
|
||||
}
|
||||
|
||||
func writeActivityDecodeError(w http.ResponseWriter, err error) {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "activity payload exceeds limit")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
|
|
|
|||
|
|
@ -238,6 +238,101 @@ func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReceiverAcceptsDomainActivityBatchAfterDurablePersistence(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
received := make(chan events.Event, 1)
|
||||
bus.Subscribe("browser.activity.batch", func(event events.Event) {
|
||||
received <- event
|
||||
})
|
||||
persisted := 0
|
||||
receiver := NewWithOptions(bus, Options{
|
||||
ActivityAvailable: func() bool { return true },
|
||||
PersistActivity: func(event events.Event) error {
|
||||
persisted++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
body := `{
|
||||
"schemaVersion": 1,
|
||||
"batchId": "batch-123",
|
||||
"createdAt": "2026-07-12T10:05:00.000Z",
|
||||
"source": "verstak-browser-extension",
|
||||
"entries": [{
|
||||
"hostname": "пример.рф",
|
||||
"startedAt": "2026-07-12T10:00:00.000Z",
|
||||
"endedAt": "2026-07-12T10:05:00.000Z",
|
||||
"durationSeconds": 300
|
||||
}]
|
||||
}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", bytes.NewBufferString(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
receiver.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
|
||||
}
|
||||
if persisted != 1 {
|
||||
t.Fatalf("persisted = %d, want 1 before acknowledgement", persisted)
|
||||
}
|
||||
var response map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("response json: %v", err)
|
||||
}
|
||||
if response["batchId"] != "batch-123" || response["status"] != "accepted" {
|
||||
t.Fatalf("response = %+v, want accepted batch-123", response)
|
||||
}
|
||||
event := <-received
|
||||
payload, ok := event.Payload.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
|
||||
}
|
||||
if payload["batchId"] != "batch-123" {
|
||||
t.Fatalf("event payload = %+v, want batch id", payload)
|
||||
}
|
||||
entries, ok := payload["entries"].([]map[string]interface{})
|
||||
if !ok || len(entries) != 1 || entries[0]["hostname"] != "xn--e1afmkfd.xn--p1ai" {
|
||||
t.Fatalf("event entries = %+v, want one canonical hostname", payload["entries"])
|
||||
}
|
||||
if _, ok := payload["url"]; ok {
|
||||
t.Fatalf("activity payload must not contain URL: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRejectsDomainActivityWithoutConsumerOrValidInterval(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
receiver *Receiver
|
||||
body string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "consumer unavailable",
|
||||
receiver: New(events.NewBus()),
|
||||
body: `{"schemaVersion":1,"batchId":"batch-a","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:05:00Z","durationSeconds":300}]}`,
|
||||
wantCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "duration too long",
|
||||
receiver: NewWithOptions(events.NewBus(), Options{
|
||||
ActivityAvailable: func() bool { return true },
|
||||
PersistActivity: func(events.Event) error { return nil },
|
||||
}),
|
||||
body: `{"schemaVersion":1,"batchId":"batch-b","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:20:01Z","durationSeconds":1201}]}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", strings.NewReader(tc.body))
|
||||
rec := httptest.NewRecorder()
|
||||
tc.receiver.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.wantCode {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.wantCode, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
received := make(chan events.Event, 1)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package hostname
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
maxDNSHostnameLength = 253
|
||||
maxDNSLabelLength = 63
|
||||
)
|
||||
|
||||
// NormalizeHostnameV1 returns the canonical A-label hostname used by domain
|
||||
// bindings. It accepts a bare DNS name, IPv4 address, bracketed IPv6 literal,
|
||||
// localhost, or a single-label internal hostname. Invalid input returns "".
|
||||
func NormalizeHostnameV1(input string) string {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\\/?#@") {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "[") || strings.HasSuffix(value, "]") {
|
||||
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
|
||||
return ""
|
||||
}
|
||||
return normalizeIPv6(value[1 : len(value)-1])
|
||||
}
|
||||
if strings.Contains(value, ":") {
|
||||
return ""
|
||||
}
|
||||
|
||||
value = strings.TrimSuffix(value, ".")
|
||||
if value == "" || strings.HasSuffix(value, ".") {
|
||||
return ""
|
||||
}
|
||||
if isNumericHostname(value) {
|
||||
return normalizeIPv4(value)
|
||||
}
|
||||
return normalizeDNS(value)
|
||||
}
|
||||
|
||||
// NormalizeURLHostnameV1 returns the canonical hostname from an HTTP(S) URL.
|
||||
// Ports, paths, credentials, and fragments are intentionally not preserved.
|
||||
func NormalizeURLHostnameV1(input string) string {
|
||||
value := strings.TrimSpace(input)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if address, err := netip.ParseAddr(host); err == nil && address.Is6() {
|
||||
return address.String()
|
||||
}
|
||||
return NormalizeHostnameV1(host)
|
||||
}
|
||||
|
||||
func normalizeIPv4(value string) string {
|
||||
address, err := netip.ParseAddr(value)
|
||||
if err != nil || !address.Is4() || address.String() != value {
|
||||
return ""
|
||||
}
|
||||
return address.String()
|
||||
}
|
||||
|
||||
func normalizeIPv6(value string) string {
|
||||
address, err := netip.ParseAddr(value)
|
||||
if err != nil || !address.Is6() {
|
||||
return ""
|
||||
}
|
||||
return address.String()
|
||||
}
|
||||
|
||||
func normalizeDNS(value string) string {
|
||||
ascii, err := idna.Lookup.ToASCII(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
ascii = strings.ToLower(strings.TrimSuffix(ascii, "."))
|
||||
if ascii == "" || strings.HasSuffix(ascii, ".") || len(ascii) > maxDNSHostnameLength {
|
||||
return ""
|
||||
}
|
||||
for _, label := range strings.Split(ascii, ".") {
|
||||
if len(label) == 0 || len(label) > maxDNSLabelLength || !isDNSLabel(label) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ascii
|
||||
}
|
||||
|
||||
func isNumericHostname(value string) bool {
|
||||
for _, char := range value {
|
||||
if (char < '0' || char > '9') && char != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isDNSLabel(label string) bool {
|
||||
if !isASCIIAlphaNum(label[0]) || !isASCIIAlphaNum(label[len(label)-1]) {
|
||||
return false
|
||||
}
|
||||
for _, char := range label {
|
||||
if !isASCIIAlphaNum(byte(char)) && char != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isASCIIAlphaNum(char byte) bool {
|
||||
return char >= 'a' && char <= 'z' || char >= '0' && char <= '9'
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package hostname
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type normalizationVector struct {
|
||||
Input string `json:"input"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type normalizationVectors struct {
|
||||
Bare []normalizationVector `json:"bare"`
|
||||
URL []normalizationVector `json:"url"`
|
||||
}
|
||||
|
||||
func TestNormalizeHostnameV1Vectors(t *testing.T) {
|
||||
vectors := loadVectors(t)
|
||||
for _, vector := range vectors.Bare {
|
||||
if got := NormalizeHostnameV1(vector.Input); got != vector.Output {
|
||||
t.Errorf("NormalizeHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeURLHostnameV1Vectors(t *testing.T) {
|
||||
vectors := loadVectors(t)
|
||||
for _, vector := range vectors.URL {
|
||||
if got := NormalizeURLHostnameV1(vector.Input); got != vector.Output {
|
||||
t.Errorf("NormalizeURLHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadVectors(t *testing.T) normalizationVectors {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", "hostname-normalization-v1.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var vectors normalizationVectors
|
||||
if err := json.Unmarshal(data, &vectors); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.mirv.top/verstak/verstak-sdk/schemas/hostname-normalization-v1.json",
|
||||
"title": "Verstak canonical hostname normalization v1 test vectors",
|
||||
"description": "Canonical hostnames are lowercase ASCII A-labels without a port or trailing DNS dot. Bare hostnames accept DNS names, IPv4, bracketed IPv6, localhost, and internal single-label names. URL inputs accept only HTTP(S). Invalid or excessively long input normalizes to an empty string.",
|
||||
"version": 1,
|
||||
"bare": [
|
||||
{ "input": "example.com", "output": "example.com" },
|
||||
{ "input": " Example.COM. ", "output": "example.com" },
|
||||
{ "input": "пример.рф", "output": "xn--e1afmkfd.xn--p1ai" },
|
||||
{ "input": "bücher.example", "output": "xn--bcher-kva.example" },
|
||||
{ "input": "127.0.0.1", "output": "127.0.0.1" },
|
||||
{ "input": "[2001:db8::1]", "output": "2001:db8::1" },
|
||||
{ "input": "localhost", "output": "localhost" },
|
||||
{ "input": "intranet", "output": "intranet" },
|
||||
{ "input": "", "output": "" },
|
||||
{ "input": "https://example.com", "output": "" },
|
||||
{ "input": "example.com:443", "output": "" },
|
||||
{ "input": "user@example.com", "output": "" },
|
||||
{ "input": "bad host", "output": "" },
|
||||
{ "input": "example..com", "output": "" },
|
||||
{ "input": "example.com..", "output": "" },
|
||||
{ "input": "127.000.000.001", "output": "" },
|
||||
{ "input": "[2001:db8::1]:443", "output": "" },
|
||||
{ "input": "a...............................................................example", "output": "" }
|
||||
],
|
||||
"url": [
|
||||
{ "input": "https://пример.рф/path", "output": "xn--e1afmkfd.xn--p1ai" },
|
||||
{ "input": "http://Example.COM.:8080/path", "output": "example.com" },
|
||||
{ "input": "https://[2001:db8::1]/", "output": "2001:db8::1" },
|
||||
{ "input": "ftp://example.com/path", "output": "" },
|
||||
{ "input": "not a URL", "output": "" }
|
||||
]
|
||||
}
|
||||
|
|
@ -20,6 +20,17 @@ type Storage struct {
|
|||
vault *vault.Vault
|
||||
}
|
||||
|
||||
// NDJSONRetention bounds append-only plugin data. Records are compacted after
|
||||
// a successful append so settings.json never becomes an event log.
|
||||
type NDJSONRetention struct {
|
||||
TimestampField string
|
||||
MaxAge time.Duration
|
||||
MaxEntries int
|
||||
MaxBytes int64
|
||||
DeduplicateField string
|
||||
DeduplicateValue string
|
||||
}
|
||||
|
||||
// New creates a new Storage instance backed by the given vault.
|
||||
func New(v *vault.Vault) *Storage {
|
||||
return &Storage{vault: v}
|
||||
|
|
@ -234,6 +245,204 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int
|
|||
return atomicWrite(path, encoded)
|
||||
}
|
||||
|
||||
// ReadPluginDataNDJSON reads an append-only named data file. A missing file is
|
||||
// represented by an empty slice.
|
||||
func (s *Storage) ReadPluginDataNDJSON(pluginID, name string) ([]map[string]interface{}, error) {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStorageName("data", name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var records []map[string]interface{}
|
||||
err := s.withOpenVault(func(vaultPath string) error {
|
||||
var err error
|
||||
records, err = readPluginDataNDJSONAt(vaultPath, pluginID, name)
|
||||
return err
|
||||
})
|
||||
return records, err
|
||||
}
|
||||
|
||||
// AppendPluginDataNDJSON appends records durably and then applies bounded
|
||||
// retention. It returns false without writing when the supplied idempotency
|
||||
// value is already present in the retained log.
|
||||
func (s *Storage) AppendPluginDataNDJSON(pluginID, name string, records []map[string]interface{}, retention NDJSONRetention) (bool, error) {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := validateStorageName("data", name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return false, fmt.Errorf("NDJSON records are empty")
|
||||
}
|
||||
if retention.MaxEntries < 0 || retention.MaxBytes < 0 || retention.MaxAge < 0 {
|
||||
return false, fmt.Errorf("NDJSON retention values must not be negative")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stored := false
|
||||
err := s.withOpenVault(func(vaultPath string) error {
|
||||
existing, err := readPluginDataNDJSONAt(vaultPath, pluginID, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if retention.DeduplicateField != "" && retention.DeduplicateValue != "" {
|
||||
for _, record := range existing {
|
||||
if fmt.Sprint(record[retention.DeduplicateField]) == retention.DeduplicateValue {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
path := pluginDataNDJSONPath(vaultPath, pluginID, name)
|
||||
if err := appendNDJSON(path, records); err != nil {
|
||||
return err
|
||||
}
|
||||
stored = true
|
||||
compacted := compactNDJSONRecords(append(existing, records...), retention, time.Now().UTC())
|
||||
if !sameNDJSONRecords(append(existing, records...), compacted) {
|
||||
return writeNDJSON(path, compacted)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return stored, err
|
||||
}
|
||||
|
||||
// WritePluginDataNDJSON replaces a named append-only data file. It is reserved
|
||||
// for explicit user actions such as clearing Activity, never normal event
|
||||
// ingestion.
|
||||
func (s *Storage) WritePluginDataNDJSON(pluginID, name string, records []map[string]interface{}) error {
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateStorageName("data", name); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.withOpenVault(func(vaultPath string) error {
|
||||
return writeNDJSON(pluginDataNDJSONPath(vaultPath, pluginID, name), records)
|
||||
})
|
||||
}
|
||||
|
||||
func pluginDataNDJSONPath(vaultPath, pluginID, name string) string {
|
||||
return filepath.Join(vaultPath, ".verstak", "plugin-data", pluginID, name+".ndjson")
|
||||
}
|
||||
|
||||
func readPluginDataNDJSONAt(vaultPath, pluginID, name string) ([]map[string]interface{}, error) {
|
||||
data, err := os.ReadFile(pluginDataNDJSONPath(vaultPath, pluginID, name))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read NDJSON data %s for plugin %s: %w", name, pluginID, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
|
||||
records := make([]map[string]interface{}, 0, len(lines))
|
||||
for index, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var record map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
return nil, fmt.Errorf("corrupt NDJSON data %s line %d for plugin %s: %w", name, index+1, pluginID, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func appendNDJSON(path string, records []map[string]interface{}) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create NDJSON data directory: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open NDJSON data file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
for _, record := range records {
|
||||
encoded, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
|
||||
}
|
||||
if _, err := file.Write(append(encoded, '\n')); err != nil {
|
||||
return fmt.Errorf("failed to append NDJSON record: %w", err)
|
||||
}
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return fmt.Errorf("failed to sync NDJSON data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeNDJSON(path string, records []map[string]interface{}) error {
|
||||
data := make([]byte, 0)
|
||||
for _, record := range records {
|
||||
encoded, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
|
||||
}
|
||||
data = append(data, encoded...)
|
||||
data = append(data, '\n')
|
||||
}
|
||||
return atomicWrite(path, data)
|
||||
}
|
||||
|
||||
func compactNDJSONRecords(records []map[string]interface{}, retention NDJSONRetention, now time.Time) []map[string]interface{} {
|
||||
kept := make([]map[string]interface{}, 0, len(records))
|
||||
cutoff := now.Add(-retention.MaxAge)
|
||||
for _, record := range records {
|
||||
if retention.MaxAge > 0 && retention.TimestampField != "" {
|
||||
if raw, ok := record[retention.TimestampField].(string); ok {
|
||||
if timestamp, err := time.Parse(time.RFC3339, raw); err == nil && timestamp.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
kept = append(kept, record)
|
||||
}
|
||||
if retention.MaxEntries > 0 && len(kept) > retention.MaxEntries {
|
||||
kept = kept[len(kept)-retention.MaxEntries:]
|
||||
}
|
||||
if retention.MaxBytes > 0 {
|
||||
for len(kept) > 0 && ndjsonSize(kept) > retention.MaxBytes {
|
||||
kept = kept[1:]
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func ndjsonSize(records []map[string]interface{}) int64 {
|
||||
var total int64
|
||||
for _, record := range records {
|
||||
encoded, err := json.Marshal(record)
|
||||
if err == nil {
|
||||
total += int64(len(encoded) + 1)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func sameNDJSONRecords(left, right []map[string]interface{}) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
leftJSON, leftErr := json.Marshal(left[index])
|
||||
rightJSON, rightErr := json.Marshal(right[index])
|
||||
if leftErr != nil || rightErr != nil || string(leftJSON) != string(rightJSON) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Cache JSON API ───────────────────────────────────────
|
||||
|
||||
// ReadPluginCacheJSON reads a named JSON cache file for a plugin.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAppendPluginDataNDJSONCompactsAndDeduplicates(t *testing.T) {
|
||||
s, vaultDir := newTestStorage(t)
|
||||
retention := NDJSONRetention{
|
||||
TimestampField: "occurredAt",
|
||||
MaxAge: 60 * 24 * time.Hour,
|
||||
MaxEntries: 2,
|
||||
MaxBytes: 16 * 1024,
|
||||
DeduplicateField: "sourceBatchId",
|
||||
DeduplicateValue: "batch-1",
|
||||
}
|
||||
old := time.Now().UTC().Add(-61 * 24 * time.Hour).Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
|
||||
{"activityId": "old", "sourceBatchId": "old-batch", "occurredAt": old},
|
||||
}, retention)
|
||||
if err != nil || !stored {
|
||||
t.Fatalf("old append = (%v, %v), want (true, nil)", stored, err)
|
||||
}
|
||||
|
||||
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
|
||||
{"activityId": "current-1", "sourceBatchId": "batch-1", "occurredAt": now},
|
||||
}, retention)
|
||||
if err != nil || !stored {
|
||||
t.Fatalf("first current append = (%v, %v), want (true, nil)", stored, err)
|
||||
}
|
||||
|
||||
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
|
||||
{"activityId": "duplicate", "sourceBatchId": "batch-1", "occurredAt": now},
|
||||
}, retention)
|
||||
if err != nil || stored {
|
||||
t.Fatalf("duplicate append = (%v, %v), want (false, nil)", stored, err)
|
||||
}
|
||||
|
||||
for _, item := range []struct {
|
||||
id string
|
||||
batch string
|
||||
}{
|
||||
{id: "current-2", batch: "batch-3"},
|
||||
{id: "current-3", batch: "batch-4"},
|
||||
} {
|
||||
retention.DeduplicateValue = item.batch
|
||||
if stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
|
||||
{"activityId": item.id, "sourceBatchId": item.batch, "occurredAt": now},
|
||||
}, retention); err != nil || !stored {
|
||||
t.Fatalf("append %s = (%v, %v), want (true, nil)", item.id, stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginDataNDJSON: %v", err)
|
||||
}
|
||||
if len(records) != 2 || records[0]["activityId"] != "current-2" || records[1]["activityId"] != "current-3" {
|
||||
t.Fatalf("records = %+v, want current-2 and current-3", records)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, "VerstakVault", ".verstak", "plugin-data", "verstak.activity", "activity-events.ndjson")); err != nil {
|
||||
t.Fatalf("activity data file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePluginDataNDJSONReplacesRecordsForExplicitUserClear(t *testing.T) {
|
||||
s, _ := newTestStorage(t)
|
||||
if _, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
|
||||
{"activityId": "one", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
|
||||
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
|
||||
}, NDJSONRetention{}); err != nil {
|
||||
t.Fatalf("AppendPluginDataNDJSON: %v", err)
|
||||
}
|
||||
if err := s.WritePluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
|
||||
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
|
||||
}); err != nil {
|
||||
t.Fatalf("WritePluginDataNDJSON: %v", err)
|
||||
}
|
||||
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginDataNDJSON: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0]["activityId"] != "two" {
|
||||
t.Fatalf("records = %+v, want only two", records)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ const (
|
|||
|
||||
// Workspace is a physical top-level workspace folder.
|
||||
type Workspace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RootPath string `json:"rootPath"`
|
||||
}
|
||||
|
|
@ -66,6 +67,7 @@ type WorkspaceTemplate struct {
|
|||
// Metadata stores semantic workspace metadata that is not the source of truth
|
||||
// for whether the workspace exists.
|
||||
type Metadata struct {
|
||||
WorkspaceID string `json:"workspaceId,omitempty"`
|
||||
WorkspaceName string `json:"workspaceName"`
|
||||
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
|
||||
Features map[string]bool `json:"features,omitempty"`
|
||||
|
|
@ -74,6 +76,12 @@ type Metadata struct {
|
|||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
const workspaceIdentityRelativePath = ".verstak/workspace.json"
|
||||
|
||||
type workspaceIdentityMarker struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
}
|
||||
|
||||
// MetadataPatch updates metadata fields without replacing unspecified fields.
|
||||
type MetadataPatch struct {
|
||||
Features map[string]bool `json:"features,omitempty"`
|
||||
|
|
@ -82,12 +90,20 @@ type MetadataPatch struct {
|
|||
|
||||
// TrashResult describes a workspace moved into the internal trash area.
|
||||
type TrashResult struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
TrashID string `json:"trashId"`
|
||||
DeletedAt string `json:"deletedAt"`
|
||||
}
|
||||
|
||||
// WorkspaceIdentity identifies an existing top-level workspace independently of its path.
|
||||
type WorkspaceIdentity struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
RootPath string `json:"rootPath"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// WorkspaceNode is a compatibility shell view of a top-level workspace.
|
||||
// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID.
|
||||
type WorkspaceNode struct {
|
||||
|
|
@ -301,7 +317,11 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
|
|||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
workspaces = append(workspaces, Workspace{Name: name, RootPath: name})
|
||||
workspaceID, err := ensureWorkspaceIdentity(filepath.Join(m.vaultDir, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaces = append(workspaces, Workspace{ID: workspaceID, Name: name, RootPath: name})
|
||||
}
|
||||
sort.Slice(workspaces, func(i, j int) bool {
|
||||
return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name)
|
||||
|
|
@ -309,6 +329,84 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
|
|||
return workspaces, nil
|
||||
}
|
||||
|
||||
// GetWorkspaceIdentity resolves the durable identity for an existing workspace.
|
||||
func (m *Manager) GetWorkspaceIdentity(name string) (WorkspaceIdentity, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if err := validateWorkspaceName(name); err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
full := filepath.Join(m.vaultDir, name)
|
||||
if err := ensureExistingWorkspaceDir(full, name); err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
workspaceID, err := ensureWorkspaceIdentity(full)
|
||||
if err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: name, State: "active"}, nil
|
||||
}
|
||||
|
||||
// ListWorkspaceIdentities returns durable identities and flags copied markers.
|
||||
func (m *Manager) ListWorkspaceIdentities() ([]WorkspaceIdentity, error) {
|
||||
workspaces, err := m.ListWorkspaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[string]int, len(workspaces))
|
||||
for _, workspace := range workspaces {
|
||||
counts[workspace.ID]++
|
||||
}
|
||||
identities := make([]WorkspaceIdentity, 0, len(workspaces))
|
||||
for _, workspace := range workspaces {
|
||||
state := "active"
|
||||
if counts[workspace.ID] > 1 {
|
||||
state = "duplicate"
|
||||
}
|
||||
identities = append(identities, WorkspaceIdentity{
|
||||
WorkspaceID: workspace.ID,
|
||||
RootPath: workspace.RootPath,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
// RepairWorkspaceIdentity keeps the identity on one copied workspace and regenerates the other.
|
||||
func (m *Manager) RepairWorkspaceIdentity(keepName, regenerateName string) error {
|
||||
keepName = strings.TrimSpace(keepName)
|
||||
regenerateName = strings.TrimSpace(regenerateName)
|
||||
if err := validateWorkspaceName(keepName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWorkspaceName(regenerateName); err != nil {
|
||||
return err
|
||||
}
|
||||
if keepName == regenerateName {
|
||||
return fmt.Errorf("workspace identity repair requires two workspaces")
|
||||
}
|
||||
keepPath := filepath.Join(m.vaultDir, keepName)
|
||||
regeneratePath := filepath.Join(m.vaultDir, regenerateName)
|
||||
if err := ensureExistingWorkspaceDir(keepPath, keepName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureExistingWorkspaceDir(regeneratePath, regenerateName); err != nil {
|
||||
return err
|
||||
}
|
||||
keepID, err := ensureWorkspaceIdentity(keepPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
regenerateID, err := ensureWorkspaceIdentity(regeneratePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if keepID != regenerateID {
|
||||
return fmt.Errorf("workspaces do not share an identity")
|
||||
}
|
||||
_, err = writeWorkspaceIdentity(regeneratePath)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateWorkspace creates a top-level workspace folder and applies a template once.
|
||||
func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
|
|
@ -339,11 +437,16 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
|||
}
|
||||
}()
|
||||
|
||||
workspaceID, err := ensureWorkspaceIdentity(full)
|
||||
if err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if err := applyTemplate(full, template); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
meta := Metadata{
|
||||
WorkspaceID: workspaceID,
|
||||
WorkspaceName: name,
|
||||
CreatedFromTemplate: &TemplateSnapshot{
|
||||
TemplateID: template.ID,
|
||||
|
|
@ -362,7 +465,82 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
|||
}
|
||||
|
||||
created = false
|
||||
return Workspace{Name: name, RootPath: name}, nil
|
||||
return Workspace{ID: workspaceID, Name: name, RootPath: name}, nil
|
||||
}
|
||||
|
||||
func writeWorkspaceIdentity(workspacePath string) (string, error) {
|
||||
workspaceID := uuid.NewString()
|
||||
data, err := json.Marshal(workspaceIdentityMarker{WorkspaceID: workspaceID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
|
||||
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := markerPath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmpPath, markerPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return "", err
|
||||
}
|
||||
return workspaceID, nil
|
||||
}
|
||||
|
||||
func ensureWorkspaceIdentity(workspacePath string) (string, error) {
|
||||
workspaceID, err := readWorkspaceIdentity(workspacePath)
|
||||
if os.IsNotExist(err) {
|
||||
return writeWorkspaceIdentity(workspacePath)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return workspaceID, nil
|
||||
}
|
||||
|
||||
func readWorkspaceIdentity(workspacePath string) (string, error) {
|
||||
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
|
||||
data, err := os.ReadFile(markerPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var marker workspaceIdentityMarker
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := uuid.Parse(marker.WorkspaceID); err != nil {
|
||||
return "", fmt.Errorf("invalid workspace identity: %w", err)
|
||||
}
|
||||
return marker.WorkspaceID, nil
|
||||
}
|
||||
|
||||
// GetWorkspaceTrashIdentity reads a trashed workspace identity without restoring it.
|
||||
func (m *Manager) GetWorkspaceTrashIdentity(trashID string) (WorkspaceIdentity, error) {
|
||||
trashID = strings.TrimSpace(trashID)
|
||||
if err := validateWorkspaceTrashID(trashID); err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
|
||||
data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json"))
|
||||
if err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
var trashMeta struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &trashMeta); err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
workspaceID, err := readWorkspaceIdentity(filepath.Join(trashDir, trashMeta.OriginalPath))
|
||||
if err != nil {
|
||||
return WorkspaceIdentity{}, err
|
||||
}
|
||||
return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: trashMeta.OriginalPath, State: "trashed"}, nil
|
||||
}
|
||||
|
||||
// ListWorkspaceTemplates returns selectable built-ins in their presentation order.
|
||||
|
|
@ -456,6 +634,10 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
|||
if err := ensureExistingWorkspaceDir(full, name); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
workspaceID, err := ensureWorkspaceIdentity(full)
|
||||
if err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
|
||||
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
|
||||
|
|
@ -468,7 +650,7 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
|||
return TrashResult{}, err
|
||||
}
|
||||
|
||||
result := TrashResult{OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt}
|
||||
result := TrashResult{WorkspaceID: workspaceID, OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt}
|
||||
trashMeta := map[string]string{
|
||||
"originalPath": name,
|
||||
"trashPath": trashRel,
|
||||
|
|
@ -500,6 +682,83 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// RestoreWorkspaceTrash restores a trashed workspace under targetName without changing its identity.
|
||||
func (m *Manager) RestoreWorkspaceTrash(trashID, targetName string) (Workspace, error) {
|
||||
trashID = strings.TrimSpace(trashID)
|
||||
targetName = strings.TrimSpace(targetName)
|
||||
if err := validateWorkspaceTrashID(trashID); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if err := validateWorkspaceName(targetName); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
|
||||
data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json"))
|
||||
if err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
var trashMeta struct {
|
||||
OriginalPath string `json:"originalPath"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &trashMeta); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
payloadPath := filepath.Join(trashDir, trashMeta.OriginalPath)
|
||||
if err := ensureExistingWorkspaceDir(payloadPath, trashMeta.OriginalPath); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
targetPath := filepath.Join(m.vaultDir, targetName)
|
||||
if _, err := os.Lstat(targetPath); err == nil {
|
||||
return Workspace{}, fmt.Errorf("conflict: %s", targetName)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if err := os.Rename(payloadPath, targetPath); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
restored := true
|
||||
defer func() {
|
||||
if restored {
|
||||
_ = os.Rename(targetPath, payloadPath)
|
||||
}
|
||||
}()
|
||||
if err := moveIfExists(filepath.Join(trashDir, "workspace.metadata.json"), m.metadataPath(targetName)); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
workspaceID, err := ensureWorkspaceIdentity(targetPath)
|
||||
if err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
if err := os.RemoveAll(trashDir); err != nil {
|
||||
return Workspace{}, err
|
||||
}
|
||||
restored = false
|
||||
return Workspace{ID: workspaceID, Name: targetName, RootPath: targetName}, nil
|
||||
}
|
||||
|
||||
func validateWorkspaceTrashID(trashID string) error {
|
||||
if trashID == "" || strings.ContainsAny(trashID, `/\\`) || filepath.Clean(trashID) != trashID {
|
||||
return fmt.Errorf("invalid workspace trash ID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeWorkspaceTrash permanently removes a workspace trash entry.
|
||||
func (m *Manager) PurgeWorkspaceTrash(trashID string) error {
|
||||
trashID = strings.TrimSpace(trashID)
|
||||
if err := validateWorkspaceTrashID(trashID); err != nil {
|
||||
return err
|
||||
}
|
||||
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
|
||||
if _, err := os.Stat(filepath.Join(trashDir, "metadata.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(trashDir)
|
||||
}
|
||||
|
||||
// GetWorkspaceMetadata returns stored metadata or safe generic metadata.
|
||||
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
|
|
|
|||
|
|
@ -292,6 +292,167 @@ func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceWritesDurableIdentityMarker(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
|
||||
markerPath := filepath.Join(vaultDir, "Client", ".verstak", "workspace.json")
|
||||
data, err := os.ReadFile(markerPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read identity marker: %v", err)
|
||||
}
|
||||
var marker map[string]string
|
||||
if err := json.Unmarshal(data, &marker); err != nil {
|
||||
t.Fatalf("decode identity marker: %v", err)
|
||||
}
|
||||
if marker["workspaceId"] == "" {
|
||||
t.Fatalf("workspaceId = %q, want UUID", marker["workspaceId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkspacesKeepsIdentityAfterRename(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
created, err := m.CreateWorkspace("Client", "default")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := m.RenameWorkspace("Client", "Client-2026"); err != nil {
|
||||
t.Fatalf("RenameWorkspace: %v", err)
|
||||
}
|
||||
|
||||
workspaces, err := m.ListWorkspaces()
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaces: %v", err)
|
||||
}
|
||||
if len(workspaces) != 1 {
|
||||
t.Fatalf("workspaces = %+v, want one", workspaces)
|
||||
}
|
||||
if workspaces[0].Name != "Client-2026" {
|
||||
t.Fatalf("workspace name = %q, want Client-2026", workspaces[0].Name)
|
||||
}
|
||||
if workspaces[0].ID != created.ID {
|
||||
t.Fatalf("workspace ID = %q, want %q", workspaces[0].ID, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkspaceIdentitiesMarksCopiedMarkerAsDuplicate(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir copied: %v", err)
|
||||
}
|
||||
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
|
||||
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
|
||||
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
|
||||
t.Fatalf("mkdir copied marker: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(originalMarker)
|
||||
if err != nil {
|
||||
t.Fatalf("read original marker: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
|
||||
t.Fatalf("write copied marker: %v", err)
|
||||
}
|
||||
|
||||
identities, err := m.ListWorkspaceIdentities()
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaceIdentities: %v", err)
|
||||
}
|
||||
if len(identities) != 2 {
|
||||
t.Fatalf("identities = %+v, want two", identities)
|
||||
}
|
||||
for _, identity := range identities {
|
||||
if identity.State != "duplicate" {
|
||||
t.Fatalf("identity %+v state = %q, want duplicate", identity, identity.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairWorkspaceIdentityRegeneratesCopiedMarker(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir copied: %v", err)
|
||||
}
|
||||
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
|
||||
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
|
||||
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
|
||||
t.Fatalf("mkdir copied marker: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(originalMarker)
|
||||
if err != nil {
|
||||
t.Fatalf("read original marker: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
|
||||
t.Fatalf("write copied marker: %v", err)
|
||||
}
|
||||
|
||||
if err := m.RepairWorkspaceIdentity("Original", "Copied"); err != nil {
|
||||
t.Fatalf("RepairWorkspaceIdentity: %v", err)
|
||||
}
|
||||
identities, err := m.ListWorkspaceIdentities()
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkspaceIdentities: %v", err)
|
||||
}
|
||||
if len(identities) != 2 || identities[0].State != "active" || identities[1].State != "active" {
|
||||
t.Fatalf("identities after repair = %+v", identities)
|
||||
}
|
||||
if identities[0].WorkspaceID == identities[1].WorkspaceID {
|
||||
t.Fatalf("repair kept duplicate ID %q", identities[0].WorkspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreWorkspaceTrashPreservesIdentity(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
created, err := m.CreateWorkspace("Client", "default")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
trashed, err := m.TrashWorkspace("Client")
|
||||
if err != nil {
|
||||
t.Fatalf("TrashWorkspace: %v", err)
|
||||
}
|
||||
restored, err := m.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored")
|
||||
if err != nil {
|
||||
t.Fatalf("RestoreWorkspaceTrash: %v", err)
|
||||
}
|
||||
if restored.Name != "Client-Restored" || restored.ID != created.ID {
|
||||
t.Fatalf("restored = %+v, want name Client-Restored and ID %q", restored, created.ID)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, "Client-Restored", ".verstak", "workspace.json")); err != nil {
|
||||
t.Fatalf("restored marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
m := NewManager(vaultDir)
|
||||
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
trashed, err := m.TrashWorkspace("Client")
|
||||
if err != nil {
|
||||
t.Fatalf("TrashWorkspace: %v", err)
|
||||
}
|
||||
if err := m.PurgeWorkspaceTrash(trashed.TrashID); err != nil {
|
||||
t.Fatalf("PurgeWorkspaceTrash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, ".verstak", "trash", "workspaces", trashed.TrashID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("workspace trash remains, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
|
||||
vaultDir := newVaultDir(t)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
|
||||
|
|
|
|||
Loading…
Reference in New Issue