feat: stabilize activity sessions for journal review
This commit is contained in:
parent
50c4fc5e68
commit
e93584c37c
|
|
@ -15,6 +15,7 @@
|
|||
var WORKSPACE_PREFIX = 'events:workspace:';
|
||||
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
|
||||
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
|
||||
var SESSION_REGISTRY_KEY = 'activity-session-registry-v2';
|
||||
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
|
||||
var MIN_SESSION_DURATION_MINUTES = 10;
|
||||
var MIN_SESSION_ACTIVITY_COUNT = 2;
|
||||
|
|
@ -32,7 +33,8 @@
|
|||
'browser.capture.link',
|
||||
'browser.capture.file',
|
||||
'browser.capture.converted',
|
||||
'browser.activity.batch'
|
||||
'browser.activity.batch',
|
||||
'activity.session.handled'
|
||||
];
|
||||
var EVENT_LABELS = {
|
||||
'workspace.selected': 'Workspace selected',
|
||||
|
|
@ -149,14 +151,16 @@
|
|||
|
||||
function scopeFromProps(props) {
|
||||
var workspaceRoot = workspaceFromProps(props);
|
||||
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
|
||||
if (!workspaceRoot) {
|
||||
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
|
||||
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '', workspaceId: '' };
|
||||
}
|
||||
return {
|
||||
mode: 'workspace',
|
||||
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
|
||||
label: workspaceRoot,
|
||||
workspaceRoot: workspaceRoot
|
||||
workspaceRoot: workspaceRoot,
|
||||
workspaceId: workspaceId
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +182,9 @@
|
|||
receivedAt: text(item.receivedAt),
|
||||
sourcePluginId: text(item.sourcePluginId || item.pluginId),
|
||||
workspaceRootPath: cleanWorkspace(item.workspaceRootPath || workspaceFromPayload(item.payload || {})),
|
||||
workspaceId: text(item.workspaceId || (item.sessionScope && item.sessionScope.workspaceId) || (item.payload && item.payload.workspaceId)),
|
||||
sessionScope: item.sessionScope && typeof item.sessionScope === 'object' ? item.sessionScope : {},
|
||||
durationSeconds: Math.max(0, Number(item.durationSeconds || (item.payload && item.payload.durationSeconds) || 0)),
|
||||
_storageKey: storageKey || '',
|
||||
payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
|
||||
};
|
||||
|
|
@ -195,6 +202,9 @@
|
|||
receivedAt: item.receivedAt,
|
||||
sourcePluginId: item.sourcePluginId,
|
||||
workspaceRootPath: item.workspaceRootPath,
|
||||
workspaceId: item.workspaceId,
|
||||
sessionScope: item.sessionScope || {},
|
||||
durationSeconds: item.durationSeconds || 0,
|
||||
payload: item.payload || {}
|
||||
};
|
||||
});
|
||||
|
|
@ -228,13 +238,22 @@
|
|||
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {})));
|
||||
}
|
||||
|
||||
function candidateScope(activity) {
|
||||
var workspaceId = text(activity && (activity.workspaceId || (activity.sessionScope && activity.sessionScope.workspaceId) || (activity.payload && activity.payload.workspaceId))).trim();
|
||||
if (workspaceId) return 'workspace:' + workspaceId;
|
||||
var workspaceRoot = candidateWorkspace(activity);
|
||||
if (workspaceRoot) return 'legacy-workspace:' + workspaceRoot;
|
||||
return 'unassigned';
|
||||
}
|
||||
|
||||
function toISOTime(time) {
|
||||
var date = new Date(time);
|
||||
return isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function candidateId(workspaceRootPath, firstActivity, lastActivity) {
|
||||
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId);
|
||||
function newSessionId() {
|
||||
if (typeof crypto !== 'undefined' && crypto && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
return 'session-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function candidateActivity(activity) {
|
||||
|
|
@ -243,57 +262,149 @@
|
|||
type: text(activity.type),
|
||||
occurredAt: toISOTime(eventTimeMs(activity)),
|
||||
sourcePluginId: text(activity.sourcePluginId),
|
||||
workspaceRootPath: candidateWorkspace(activity)
|
||||
workspaceRootPath: candidateWorkspace(activity),
|
||||
workspaceId: text(activity.workspaceId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionRegistry(value) {
|
||||
value = value && typeof value === 'object' ? value : {};
|
||||
return {
|
||||
sessions: value.sessions && typeof value.sessions === 'object' ? value.sessions : {},
|
||||
eventSessionIds: value.eventSessionIds && typeof value.eventSessionIds === 'object' ? value.eventSessionIds : {}
|
||||
};
|
||||
}
|
||||
|
||||
function pruneSessionRegistry(registry, activityList) {
|
||||
var present = {};
|
||||
(activityList || []).forEach(function (activity) { present[text(activity && activity.activityId)] = true; });
|
||||
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
|
||||
if (!present[activityId]) delete registry.eventSessionIds[activityId];
|
||||
});
|
||||
var referenced = {};
|
||||
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
|
||||
referenced[registry.eventSessionIds[activityId]] = true;
|
||||
});
|
||||
Object.keys(registry.sessions).forEach(function (sessionId) {
|
||||
if (!referenced[sessionId]) delete registry.sessions[sessionId];
|
||||
});
|
||||
return registry;
|
||||
}
|
||||
|
||||
function sessionDurationMinutes(session) {
|
||||
var events = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
|
||||
var durationMs = 0;
|
||||
for (var index = 0; index < events.length; index += 1) {
|
||||
var explicit = Math.max(0, Number(events[index].durationSeconds || 0)) * 1000;
|
||||
durationMs += explicit;
|
||||
if (index === 0 || explicit > 0 || Number(events[index - 1].durationSeconds || 0) > 0) continue;
|
||||
var gap = eventTimeMs(events[index]) - eventTimeMs(events[index - 1]);
|
||||
if (gap > 0 && gap <= MAX_IDLE_GAP_MINUTES * 60 * 1000) durationMs += Math.min(gap, 10 * 60 * 1000);
|
||||
}
|
||||
return Math.min(MAX_SESSION_DURATION_MINUTES, Math.floor(durationMs / 60000));
|
||||
}
|
||||
|
||||
function findCompatibleSession(sessions, scope, time) {
|
||||
var nearest = null;
|
||||
sessions.forEach(function (session) {
|
||||
if (session.scope !== scope) return;
|
||||
var before = session.firstTime - time;
|
||||
var after = time - session.lastTime;
|
||||
var distance = time < session.firstTime ? before : after;
|
||||
if (distance < 0 || distance > MAX_IDLE_GAP_MINUTES * 60 * 1000) return;
|
||||
if (Math.max(session.lastTime, time) - Math.min(session.firstTime, time) > MAX_SESSION_DURATION_MINUTES * 60 * 1000) return;
|
||||
if (!nearest || distance < nearest.distance) nearest = { session: session, distance: distance };
|
||||
});
|
||||
return nearest && nearest.session;
|
||||
}
|
||||
|
||||
function logicalSessions(activityList, registry) {
|
||||
var sessionsById = {};
|
||||
var sessions = [];
|
||||
var lastScope = '';
|
||||
var ordered = sortEvents(activityList || []).filter(function (activity) {
|
||||
return isMeaningfulActivity(activity) && eventTimeMs(activity);
|
||||
}).slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
|
||||
ordered.forEach(function (activity) {
|
||||
var scope = candidateScope(activity);
|
||||
var activityId = text(activity.activityId);
|
||||
var sessionId = text(registry.eventSessionIds[activityId]);
|
||||
var session = sessionId && sessionsById[sessionId];
|
||||
if (!session && sessionId && registry.sessions[sessionId]) {
|
||||
var persisted = registry.sessions[sessionId];
|
||||
session = {
|
||||
sessionId: sessionId,
|
||||
scope: text(persisted.scope || scope),
|
||||
workspaceRootPath: cleanWorkspace(persisted.workspaceRootPath || candidateWorkspace(activity)),
|
||||
workspaceId: text(persisted.workspaceId || activity.workspaceId),
|
||||
anchor: text(persisted.anchor),
|
||||
firstTime: eventTimeMs(activity),
|
||||
lastTime: eventTimeMs(activity),
|
||||
activities: []
|
||||
};
|
||||
sessionsById[sessionId] = session;
|
||||
sessions.push(session);
|
||||
}
|
||||
if (!session && (!lastScope || lastScope === scope)) {
|
||||
session = findCompatibleSession(sessions, scope, eventTimeMs(activity));
|
||||
}
|
||||
if (!session) {
|
||||
sessionId = newSessionId();
|
||||
session = {
|
||||
sessionId: sessionId,
|
||||
scope: scope,
|
||||
workspaceRootPath: candidateWorkspace(activity),
|
||||
workspaceId: text(activity.workspaceId),
|
||||
anchor: new Date().toISOString(),
|
||||
firstTime: eventTimeMs(activity),
|
||||
lastTime: eventTimeMs(activity),
|
||||
activities: []
|
||||
};
|
||||
sessionsById[sessionId] = session;
|
||||
sessions.push(session);
|
||||
registry.sessions[sessionId] = {
|
||||
scope: session.scope,
|
||||
workspaceRootPath: session.workspaceRootPath,
|
||||
workspaceId: session.workspaceId,
|
||||
anchor: session.anchor
|
||||
};
|
||||
}
|
||||
registry.eventSessionIds[activityId] = session.sessionId;
|
||||
session.activities.push(activity);
|
||||
session.firstTime = Math.min(session.firstTime, eventTimeMs(activity));
|
||||
session.lastTime = Math.max(session.lastTime, eventTimeMs(activity));
|
||||
if (candidateWorkspace(activity)) session.workspaceRootPath = candidateWorkspace(activity);
|
||||
if (activity.workspaceId) session.workspaceId = text(activity.workspaceId);
|
||||
lastScope = scope;
|
||||
});
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function buildCandidate(session) {
|
||||
var first = session.activities[0];
|
||||
var last = session.activities[session.activities.length - 1];
|
||||
var duration = Math.round((eventTimeMs(last) - eventTimeMs(first)) / 60000);
|
||||
if (session.activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
|
||||
var activities = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
|
||||
var first = activities[0];
|
||||
var last = activities[activities.length - 1];
|
||||
var duration = sessionDurationMinutes(session);
|
||||
if (session.scope === 'unassigned' || !session.workspaceRootPath || activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
|
||||
return {
|
||||
candidateId: candidateId(session.workspaceRootPath, first, last),
|
||||
candidateId: 'work-session:' + encodeKey(session.sessionId) + ':' + encodeKey(last.activityId),
|
||||
sessionId: session.sessionId,
|
||||
handledThrough: toISOTime(eventTimeMs(last)),
|
||||
workspaceRootPath: session.workspaceRootPath,
|
||||
workspaceId: session.workspaceId,
|
||||
startedAt: toISOTime(eventTimeMs(first)),
|
||||
endedAt: toISOTime(eventTimeMs(last)),
|
||||
estimatedMinutes: duration,
|
||||
activityCount: session.activities.length,
|
||||
activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
|
||||
activities: session.activities.map(candidateActivity)
|
||||
activityCount: activities.length,
|
||||
activityIds: activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
|
||||
activities: activities.map(candidateActivity)
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkSessionCandidates(activityList, workspaceFilter) {
|
||||
function buildWorkSessionCandidates(activityList, workspaceFilter, registry) {
|
||||
var filter = cleanWorkspace(workspaceFilter);
|
||||
var ordered = sortEvents(activityList || []).filter(function (activity) {
|
||||
return isMeaningfulActivity(activity) && candidateWorkspace(activity) && eventTimeMs(activity);
|
||||
}).slice().sort(function (a, b) {
|
||||
return eventTimeMs(a) - eventTimeMs(b);
|
||||
});
|
||||
var sessions = [];
|
||||
var current = null;
|
||||
ordered.forEach(function (activity) {
|
||||
var workspace = candidateWorkspace(activity);
|
||||
var time = eventTimeMs(activity);
|
||||
if (!current) {
|
||||
current = { workspaceRootPath: workspace, activities: [activity] };
|
||||
return;
|
||||
}
|
||||
var firstTime = eventTimeMs(current.activities[0]);
|
||||
var lastTime = eventTimeMs(current.activities[current.activities.length - 1]);
|
||||
var switchedWorkspace = current.workspaceRootPath !== workspace;
|
||||
var idleGap = time - lastTime > MAX_IDLE_GAP_MINUTES * 60 * 1000;
|
||||
var exceededMaximum = time - firstTime > MAX_SESSION_DURATION_MINUTES * 60 * 1000;
|
||||
if (switchedWorkspace || idleGap || exceededMaximum) {
|
||||
sessions.push(current);
|
||||
current = { workspaceRootPath: workspace, activities: [activity] };
|
||||
return;
|
||||
}
|
||||
current.activities.push(activity);
|
||||
});
|
||||
if (current) sessions.push(current);
|
||||
return sessions.map(buildCandidate).filter(function (candidate) {
|
||||
registry = normalizeSessionRegistry(registry);
|
||||
return logicalSessions(activityList, registry).map(buildCandidate).filter(function (candidate) {
|
||||
return candidate && (!filter || candidate.workspaceRootPath === filter);
|
||||
}).sort(function (a, b) {
|
||||
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
|
||||
|
|
@ -379,26 +490,45 @@
|
|||
function dismissedCandidatesFromSettings(settings) {
|
||||
var dismissed = {};
|
||||
Object.keys(settings || {}).forEach(function (key) {
|
||||
if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return;
|
||||
if (key.indexOf(DISMISSAL_PREFIX) !== 0) return;
|
||||
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
|
||||
if (!workspace) return;
|
||||
dismissed[workspace] = {};
|
||||
settings[key].forEach(function (candidateId) {
|
||||
candidateId = text(candidateId).trim();
|
||||
if (candidateId) dismissed[workspace][candidateId] = true;
|
||||
});
|
||||
if (Array.isArray(settings[key])) {
|
||||
settings[key].forEach(function (candidateId) {
|
||||
candidateId = text(candidateId).trim();
|
||||
if (candidateId) dismissed[workspace][candidateId] = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (settings[key] && typeof settings[key] === 'object') dismissed[workspace] = settings[key];
|
||||
});
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
function isCandidateDismissed(candidate, dismissedByWorkspace) {
|
||||
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]);
|
||||
function candidateAfterWatermark(candidate, watermark) {
|
||||
if (!watermark || watermark === true) return watermark === true ? null : candidate;
|
||||
var handledTime = Date.parse(watermark.handledThrough || '');
|
||||
if (!Number.isFinite(handledTime)) return candidate;
|
||||
var additions = candidate.activities.filter(function (activity) { return eventTimeMs(activity) > handledTime; });
|
||||
if (eventTimeMs({ occurredAt: candidate.endedAt }) <= handledTime || additions.length < MIN_SESSION_ACTIVITY_COUNT || eventTimeMs(additions[additions.length - 1]) - handledTime < MIN_SESSION_DURATION_MINUTES * 60 * 1000) return null;
|
||||
var duration = sessionDurationMinutes({ activities: additions });
|
||||
if (duration < MIN_SESSION_DURATION_MINUTES) return null;
|
||||
return Object.assign({}, candidate, {
|
||||
startedAt: toISOTime(eventTimeMs(additions[0])),
|
||||
estimatedMinutes: duration,
|
||||
activityCount: additions.length,
|
||||
activityIds: additions.map(function (activity) { return activity.activityId; }),
|
||||
activities: additions.map(candidateActivity)
|
||||
});
|
||||
}
|
||||
|
||||
function visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) {
|
||||
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) {
|
||||
return !isCandidateDismissed(candidate, dismissedByWorkspace);
|
||||
});
|
||||
function visibleCandidates(activityList, workspaceFilter, sessionRegistry, dismissedByWorkspace, handledSessions) {
|
||||
return buildWorkSessionCandidates(activityList, workspaceFilter, sessionRegistry).map(function (candidate) {
|
||||
var dismissed = dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath];
|
||||
var watermark = handledSessions && handledSessions[candidate.sessionId];
|
||||
return candidateAfterWatermark(candidate, watermark || (dismissed && (dismissed[candidate.sessionId] || dismissed[candidate.candidateId])));
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
|
|
@ -421,6 +551,8 @@
|
|||
var candidateSourceEvents = [];
|
||||
var candidates = [];
|
||||
var dismissedByWorkspace = {};
|
||||
var handledSessions = {};
|
||||
var sessionRegistry = normalizeSessionRegistry({});
|
||||
function tr(key, params, fallback) {
|
||||
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
|
||||
return fallback || key;
|
||||
|
|
@ -467,7 +599,7 @@
|
|||
containerEl.appendChild(listEl);
|
||||
|
||||
function candidatesForWorkspace(workspaceRoot) {
|
||||
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace);
|
||||
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
|
||||
}
|
||||
|
||||
function updateCandidates() {
|
||||
|
|
@ -496,7 +628,12 @@
|
|||
function persistDismissals(workspaceRoot) {
|
||||
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||
var dismissed = dismissedByWorkspace[workspaceRoot] || {};
|
||||
return api.settings.write(dismissalStorageKey(workspaceRoot), Object.keys(dismissed));
|
||||
return api.settings.write(dismissalStorageKey(workspaceRoot), dismissed);
|
||||
}
|
||||
|
||||
function persistSessionRegistry() {
|
||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||
return api.settings.write(SESSION_REGISTRY_KEY, sessionRegistry);
|
||||
}
|
||||
|
||||
function persist() {
|
||||
|
|
@ -504,7 +641,7 @@
|
|||
var toStore = scope.mode === 'global'
|
||||
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
|
||||
: events;
|
||||
return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) {
|
||||
return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
|
||||
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save activity: ' + (err && err.message ? err.message : String(err)));
|
||||
statusClass = 'error';
|
||||
});
|
||||
|
|
@ -528,6 +665,8 @@
|
|||
candidateSourceEvents = [];
|
||||
candidates = [];
|
||||
dismissedByWorkspace = {};
|
||||
handledSessions = {};
|
||||
sessionRegistry = normalizeSessionRegistry({});
|
||||
return Promise.all(keys.filter(function (key, index, all) {
|
||||
return all.indexOf(key) === index;
|
||||
}).map(function (key) {
|
||||
|
|
@ -602,7 +741,11 @@
|
|||
function dismissCandidate(candidate) {
|
||||
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
|
||||
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
|
||||
dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true;
|
||||
dismissedByWorkspace[candidate.workspaceRootPath][candidate.sessionId || candidate.candidateId] = {
|
||||
handledThrough: candidate.handledThrough || candidate.endedAt,
|
||||
handledAt: new Date().toISOString(),
|
||||
status: 'dismissed'
|
||||
};
|
||||
updateCandidates();
|
||||
Promise.all([
|
||||
persistDismissals(candidate.workspaceRootPath),
|
||||
|
|
@ -671,13 +814,18 @@
|
|||
var settings = results[0] || {};
|
||||
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
|
||||
settings = settings || {};
|
||||
sessionRegistry = normalizeSessionRegistry(settings[SESSION_REGISTRY_KEY]);
|
||||
handledSessions = settings["activity-session-handling-v2"] && typeof settings["activity-session-handling-v2"] === 'object'
|
||||
? settings["activity-session-handling-v2"]
|
||||
: {};
|
||||
candidateSourceEvents = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
|
||||
sessionRegistry = pruneSessionRegistry(sessionRegistry, candidateSourceEvents);
|
||||
events = rawRecords.length
|
||||
? eventsFromRecords(rawRecords, scope.mode === 'workspace' ? scope.workspaceRoot : '')
|
||||
: eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
||||
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
|
||||
updateCandidates();
|
||||
return persistCandidateCaches();
|
||||
return persistSessionRegistry().then(persistCandidateCaches);
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
|
|
@ -687,7 +835,7 @@
|
|||
function listWorkSessionCandidates(args) {
|
||||
var workspace = cleanWorkspace(args && args.workspaceRootPath);
|
||||
if (!api || !api.settings || typeof api.settings.read !== 'function') {
|
||||
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), dismissedByWorkspace) });
|
||||
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), sessionRegistry, dismissedByWorkspace, handledSessions) });
|
||||
}
|
||||
var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
|
||||
? api.storage.data.readNDJSON(RAW_DATA_NAME)
|
||||
|
|
@ -696,7 +844,7 @@
|
|||
var settings = results[0] || {};
|
||||
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
|
||||
var source = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
|
||||
return { candidates: visibleCandidates(source, workspace, dismissedCandidatesFromSettings(settings)) };
|
||||
return { candidates: visibleCandidates(source, workspace, sessionRegistry, dismissedCandidatesFromSettings(settings), settings["activity-session-handling-v2"] || {}) };
|
||||
}).catch(function () {
|
||||
return { candidates: [] };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -227,6 +227,8 @@
|
|||
}
|
||||
return {
|
||||
candidateId: text(value.candidateId),
|
||||
sessionId: text(value.sessionId),
|
||||
handledThrough: text(value.handledThrough || value.endedAt),
|
||||
workspaceRootPath: workspace,
|
||||
startedAt: text(value.startedAt),
|
||||
endedAt: text(value.endedAt),
|
||||
|
|
@ -368,6 +370,8 @@
|
|||
minutes: minutesInput.value,
|
||||
billable: billableInput.checked === true,
|
||||
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
|
||||
sessionId: reviewingCandidate ? candidate.sessionId : '',
|
||||
handledThrough: reviewingCandidate ? candidate.handledThrough : '',
|
||||
sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''),
|
||||
activityIds: reviewingCandidate
|
||||
? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; })
|
||||
|
|
@ -431,6 +435,8 @@
|
|||
return;
|
||||
}
|
||||
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
|
||||
var sessionID = text(formValue && formValue.sessionId).trim();
|
||||
var handledThrough = text(formValue && formValue.handledThrough).trim();
|
||||
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
|
||||
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
|
||||
statusText = 'A journal entry already references this candidate';
|
||||
|
|
@ -467,7 +473,14 @@
|
|||
closeEntryModal();
|
||||
statusText = existingEntry ? 'Entry updated' : 'Entry added';
|
||||
statusClass = '';
|
||||
persist().then(render);
|
||||
persist().then(function () {
|
||||
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
|
||||
return api.events.publish('activity.session.handled', {
|
||||
sessionId: sessionID,
|
||||
handledThrough: handledThrough,
|
||||
status: 'accepted'
|
||||
});
|
||||
}).then(render);
|
||||
}
|
||||
|
||||
function deleteEntry(entry) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"report.worklog"
|
||||
],
|
||||
"permissions": [
|
||||
"events.publish",
|
||||
"storage.namespace",
|
||||
"ui.register"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
|
||||
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
|
||||
if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered');
|
||||
if (!candidateNode.textContent.includes('Estimated duration: 30 min')) throw new Error('candidate duration was not rendered');
|
||||
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
|
||||
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
|
||||
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
|
||||
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered');
|
||||
|
|
@ -299,7 +299,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
if (!candidate.candidateId) throw new Error('candidate id is missing');
|
||||
if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch');
|
||||
if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch');
|
||||
if (candidate.estimatedMinutes !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`);
|
||||
if (candidate.estimatedMinutes !== 20) throw new Error(`expected 20 candidate minutes, got ${candidate.estimatedMinutes}`);
|
||||
if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`);
|
||||
if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch');
|
||||
if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing');
|
||||
|
|
@ -365,7 +365,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
|
||||
|
||||
component.unmount && component.unmount(container);
|
||||
if (api.unsubscribed.length !== 36) throw new Error(`expected 36 unsubscribers, got ${api.unsubscribed.length}`);
|
||||
if (api.unsubscribed.length !== 39) throw new Error(`expected 39 unsubscribers, got ${api.unsubscribed.length}`);
|
||||
|
||||
const persistedApi = makeApi({
|
||||
'events:workspace:Project': [{
|
||||
|
|
@ -461,6 +461,57 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||
}
|
||||
component.unmount && component.unmount(sessionView.container);
|
||||
|
||||
const lateApi = makeApi({
|
||||
'events:workspace:Project': [
|
||||
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
|
||||
],
|
||||
});
|
||||
const lateView = await mountWithApi(lateApi);
|
||||
const lateCommand = lateApi.commandHandlers.get(WORKLOG_COMMAND_ID);
|
||||
const firstLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
|
||||
if (!firstLateCandidate || !firstLateCandidate.sessionId || firstLateCandidate.estimatedMinutes !== 10) {
|
||||
throw new Error('file activity duration must use the capped adjacent-event algorithm');
|
||||
}
|
||||
await lateApi.settings.write('events:workspace:Project', [
|
||||
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
|
||||
]);
|
||||
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
|
||||
await flush();
|
||||
const afterLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
|
||||
if (!afterLateCandidate || afterLateCandidate.sessionId !== firstLateCandidate.sessionId) {
|
||||
throw new Error('late events changed an immutable session identity');
|
||||
}
|
||||
const dismissLate = walk(lateView.container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss');
|
||||
dismissLate.click();
|
||||
await flush();
|
||||
await lateApi.settings.write('events:workspace:Project', [
|
||||
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
|
||||
]);
|
||||
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
|
||||
await flush();
|
||||
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 0) {
|
||||
throw new Error('dismissed session was re-offered without substantial new activity');
|
||||
}
|
||||
await lateApi.settings.write('events:workspace:Project', [
|
||||
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
|
||||
{ activityId: 'late-two', type: 'file.changed', occurredAt: '2026-07-12T10:25:00Z', workspaceRootPath: 'Project' },
|
||||
]);
|
||||
await lateApi.handlers['file.changed']({ name: 'file.changed', payload: {} });
|
||||
await flush();
|
||||
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 1) {
|
||||
throw new Error('dismissed session was not re-offered after substantial new activity');
|
||||
}
|
||||
component.unmount && component.unmount(lateView.container);
|
||||
|
||||
const rawApi = makeApi({}, {
|
||||
'activity-events': [{
|
||||
activityId: 'browser-domain:batch-1:0',
|
||||
|
|
|
|||
|
|
@ -133,7 +133,9 @@ function loadComponent(document) {
|
|||
|
||||
function makeApi(initialSettings = {}) {
|
||||
const settings = { ...initialSettings };
|
||||
const publishedEvents = [];
|
||||
return {
|
||||
publishedEvents,
|
||||
settings: {
|
||||
read: async (key) => (key ? settings[key] : { ...settings }),
|
||||
write: async (key, value) => {
|
||||
|
|
@ -141,6 +143,11 @@ function makeApi(initialSettings = {}) {
|
|||
return { ...settings };
|
||||
},
|
||||
},
|
||||
events: {
|
||||
publish: async (name, payload) => {
|
||||
publishedEvents.push({ name, payload });
|
||||
},
|
||||
},
|
||||
storedEntries(key) {
|
||||
return settings[key] || [];
|
||||
},
|
||||
|
|
@ -172,6 +179,7 @@ function byData(container, attr, value) {
|
|||
}
|
||||
if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity');
|
||||
if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace');
|
||||
if (!manifest.permissions.includes('events.publish')) throw new Error('journal manifest must request events.publish');
|
||||
if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register');
|
||||
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing');
|
||||
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.journal.view')) throw new Error('journal sidebar item missing');
|
||||
|
|
@ -212,6 +220,8 @@ function byData(container, attr, value) {
|
|||
|
||||
const candidate = {
|
||||
candidateId: 'work-session:Project:capture-1:note-1',
|
||||
sessionId: 'session-journal-1',
|
||||
handledThrough: '2026-06-27T11:03:00.000Z',
|
||||
workspaceRootPath: 'Project',
|
||||
startedAt: '2026-06-27T10:12:00.000Z',
|
||||
endedAt: '2026-06-27T11:03:00.000Z',
|
||||
|
|
@ -249,6 +259,10 @@ function byData(container, attr, value) {
|
|||
throw new Error('candidate review did not keep the user-authored entry fields');
|
||||
}
|
||||
if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids');
|
||||
const handledEvent = api.publishedEvents.find((event) => event.name === 'activity.session.handled');
|
||||
if (!handledEvent || handledEvent.payload.sessionId !== 'session-journal-1' || handledEvent.payload.handledThrough !== '2026-06-27T11:03:00.000Z' || handledEvent.payload.status !== 'accepted') {
|
||||
throw new Error('accepted Journal candidate did not persist a session watermark');
|
||||
}
|
||||
if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) {
|
||||
throw new Error('journal rows must not navigate to Activity by default');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue