Skip to content
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Description formats:
| 063 | Infrastructure | [Analyse tool specs for phased implementation sequence](docs/ideas/063-tool-implementation-sequence.md) [E01] — dependency graph, phase groupings, and per-phase backlog items for 63 documented tools | 5 | 3 | 4 | 12 | Medium | approved |
| ~~062~~ | ~~Tech Debt~~ | ~~[Compound track model with embedded children](specs/062-missing-feature-kind-enum-values/spec.md) [E01] — extend TrackFeature with MultiLineString compound geometry, embedded sensors, TUAs, and per-segment metadata; blocks 30+ tool implementations~~ | ~~5~~ | ~~2~~ | ~~5~~ | ~~12~~ | ~~Medium~~ | ~~complete~~ |
| 070 | Infrastructure | [Implement PROV schema foundation](specs/070-prov-schema-foundation/spec.md) [E02] — LinkML Log Entry schema, expanded ToolResult model, provenance migration, system record (requires #062) | 5 | 3 | 4 | 12 | High | implementing |
| 071 | Feature | [Implement Log Recording service](docs/ideas/071-log-recording-service.md) [E02] — TypeScript Log Service, recordToolResult, getTimeline, session-state integration (requires #070) | 5 | 4 | 3 | 12 | High | approved |
| 071 | Feature | [Implement Log Recording service](specs/071-log-recording-service/spec.md) [E02] — TypeScript Log Service, recordToolResult, getTimeline, session-state integration (requires #070) | 5 | 4 | 3 | 12 | High | specified |
| 072 | Feature | [Implement Log Panel](docs/ideas/072-log-panel.md) [E02] — VS Code activity panel, timeline view, entry display, filter/search (requires #071, optionally #044) | 5 | 5 | 3 | 13 | High | approved |
| 073 | Tech Debt | [Split undo/redo: UI-only undo, data changes via Log](docs/ideas/073-undo-redo-split.md) [E02] — narrow StateSnapshot, remove featureCollectionUri and savePath (requires #071) | 4 | 2 | 5 | 11 | Low | approved |
| 074 | Feature | [Implement snapshots with doubly-linked chain](docs/ideas/074-snapshots.md) [E02] — clean-state checkpoints, snapshot assets in STAC (requires #071) | 5 | 3 | 3 | 11 | Medium | approved |
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ Institutional knowledge lives in `docs/project_notes/` for consistency across se
- Python 3.11 (LinkML schemas, Pydantic v2 models), TypeScript 5.x (generated types) + LinkML 1.7+, gen-pydantic, gen-json-schema, gen-typescript (existing schema generators) (062-missing-feature-kind-enum-values)
- Local filesystem (STAC catalogs with GeoJSON payloads) (062-missing-feature-kind-enum-values)
- Python 3.11 (LinkML schemas, Pydantic models), standard library only (plus `pydantic>=2.0.0`) + LinkML >= 1.7.0 (schema definition + generators), Pydantic v2 (Python model validation) (070-prov-schema-foundation)
- TypeScript 5.x (Log Service, type updates, VS Code extension, web-shell) + Zustand ^5.0.0 (session-state store), existing stacService (file I/O), existing calcService (MCP parsing). No new external dependencies. (071-log-recording-service)
- Local filesystem -- GeoJSON files within STAC Item directories (read/write via stacService) (071-log-recording-service)

## Recent Changes
- 039-wire-timecontroller-temporal-track: Added TypeScript 5.x (VS Code extension webview) + Leaflet (vanilla JS), VS Code webview API, `@debrief/session-state` (Zustand store)
37 changes: 36 additions & 1 deletion apps/vscode/src/commands/executeTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { ToolMatchAdapter } from '../services/toolMatchAdapter';
import type { MapPanel } from '../webview/mapPanel';
import type { LayersTreeProvider } from '../providers/layersTreeProvider';
import type { ActivityPanelViewProvider } from '../views/activityPanelView';
import type { LogService } from '@debrief/session-state';

/**
* Create the execute tool command
Expand All @@ -25,14 +26,16 @@ import type { ActivityPanelViewProvider } from '../views/activityPanelView';
* @param layersTreeProvider - LayersTreeProvider for displaying results
* @param stacService - StacService for persisting results to STAC
* @param activityPanelProvider - ActivityPanelViewProvider for updating result files
* @param logService - LogService for recording provenance (Feature: 071)
*/
export function createExecuteToolCommand(
calcService: CalcService,
toolMatchAdapter: ToolMatchAdapter,
getMapPanel: () => MapPanel | undefined,
layersTreeProvider: LayersTreeProvider,
stacService?: StacService,
activityPanelProvider?: ActivityPanelViewProvider
activityPanelProvider?: ActivityPanelViewProvider,
logService?: LogService
): (toolId: string) => Promise<void> {
return async (toolId: string) => {
// Handle both new format (toolId string) and legacy format (object with toolName)
Expand Down Expand Up @@ -156,6 +159,38 @@ export function createExecuteToolCommand(
}
}

// Record provenance via Log Service (Feature: 071)
if (logService && stacService) {
try {
const store = panel.getCurrentStore?.();
const plot = panel.getCurrentPlot?.();
if (store?.path && plot?.itemPath) {
await logService.recordToolResult(
{
success: true,
features: result.features,
durationMs: result.durationMs,
resultType: result.resultType,
sourceFeatureIds: result.sourceFeatureIds ?? selectedFeatureIds,
artifactHref: result.artifactHref,
toolId: resolvedToolId,
},
result.toolVersion || result.parameters ? {
toolVersion: result.toolVersion,
modifiedFeatures: result.modifiedFeatures,
createdFeatures: result.createdFeatures,
createdAssets: result.createdAssets,
parameters: result.parameters,
} : undefined,
store.path,
plot.itemPath
);
}
} catch (logErr) {
console.warn('[debrief] Failed to record provenance:', logErr);
}
}

// Success notification (FR-015)
void vscode.window.showInformationMessage(
`Analysis complete: ${layer.name}`
Expand Down
34 changes: 33 additions & 1 deletion apps/vscode/src/services/calcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@
sourceFeatureIds: result.sourceFeatureIds,
artifactData: result.artifactData,
artifactHref: result.artifactHref,
toolVersion: result.toolVersion,
modifiedFeatures: result.modifiedFeatures,
createdFeatures: result.createdFeatures,
createdAssets: result.createdAssets,
parameters: result.parameters,
};
} catch (err) {
execution.status = 'failed';
Expand Down Expand Up @@ -629,7 +634,7 @@
toolId: string,
featureIds: string[],
params?: Record<string, unknown>
): Promise<{ features: SafeFeatureCollection; resultType?: string; label?: string; sourceFeatureIds?: string[]; artifactData?: string; artifactHref?: string }> {
): Promise<{ features: SafeFeatureCollection; resultType?: string; label?: string; sourceFeatureIds?: string[]; artifactData?: string; artifactHref?: string; toolVersion?: string; modifiedFeatures?: ToolExecutionResult['modifiedFeatures']; createdFeatures?: string[]; createdAssets?: ToolExecutionResult['createdAssets']; parameters?: ToolExecutionResult['parameters'] }> {
const features = this.resolveFeatures(featureIds);

const input = JSON.stringify({
Expand Down Expand Up @@ -663,15 +668,37 @@
let sourceFeatureIds: string[] | undefined;
let artifactData: string | undefined;
let artifactHref: string | undefined;
let toolVersion: string | undefined;
let modifiedFeatures: ToolExecutionResult['modifiedFeatures'] | undefined;
let createdFeatures: string[] | undefined;
let createdAssets: ToolExecutionResult['createdAssets'] | undefined;
let parameters: ToolExecutionResult['parameters'] | undefined;

for (const item of response.content) {
// Grab annotations from first item
if (!resultType && item.annotations) {

Check warning on line 679 in apps/vscode/src/services/calcService.ts

View workflow job for this annotation

GitHub Actions / Build & Test

Unexpected object value in conditional. The condition is always true
resultType = item.annotations['debrief:resultType'];
label = item.annotations['debrief:label'];
sourceFeatureIds = item.annotations['debrief:sourceFeatures'];
}

// Parse expanded ToolResult fields (Phase 0, Feature 071)
if (item.annotations?.['debrief:toolVersion']) {
toolVersion = item.annotations['debrief:toolVersion'];
}
if (item.annotations?.['debrief:modifiedFeatures']) {
modifiedFeatures = item.annotations['debrief:modifiedFeatures'];
}
if (item.annotations?.['debrief:createdFeatures']) {
createdFeatures = item.annotations['debrief:createdFeatures'];
}
if (item.annotations?.['debrief:createdAssets']) {
createdAssets = item.annotations['debrief:createdAssets'];
}
if (item.annotations?.['debrief:parameters']) {
parameters = item.annotations['debrief:parameters'];
}

// Detect artifact items via debrief:href annotation
if (item.annotations?.['debrief:href']) {
artifactHref = item.annotations['debrief:href'];
Expand All @@ -694,6 +721,11 @@
sourceFeatureIds,
artifactData,
artifactHref,
toolVersion,
modifiedFeatures,
createdFeatures,
createdAssets,
parameters,
};
}
}
117 changes: 117 additions & 0 deletions apps/vscode/src/services/stacService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@
const geom = feature.geometry;

// Skip features with no geometry or empty coordinates
if (!geom || !geom.coordinates || (Array.isArray(geom.coordinates) && geom.coordinates.length === 0)) {

Check warning on line 341 in apps/vscode/src/services/stacService.ts

View workflow job for this annotation

GitHub Actions / Build & Test

Unexpected object value in conditional. The condition is always true
continue;
}

Expand Down Expand Up @@ -1017,6 +1017,123 @@
return featureCollection.features.length;
}

/**
* Append provenance Log entries to existing features in a STAC item's GeoJSON.
* Feature: 071-log-recording-service
*
* @param storePath Path to the STAC store root
* @param itemPath Relative path to the item JSON file
* @param provenance Array of feature ID + Log entry pairs
* @returns Number of features successfully updated
*/
async appendProvenance(
storePath: string,
itemPath: string,
provenance: Array<{ featureId: string; entry: Record<string, unknown> }>
): Promise<number> {
const fullItemPath = path.join(storePath, itemPath);
const item = await this.loadItem(fullItemPath);

if (!item) {
throw new Error(`Item not found: ${itemPath}`);
}

// Find GeoJSON asset
const geoJsonAsset = Object.values(item.assets).find(
(asset) =>
asset.type === 'application/geo+json' ||
asset.href.endsWith('.geojson')
);

if (!geoJsonAsset) {
throw new Error(`No GeoJSON asset found for item: ${itemPath}`);
}

const itemDir = path.dirname(fullItemPath);
const geoJsonPath = path.resolve(itemDir, geoJsonAsset.href);
const featureCollection = await this.loadGeoJson(geoJsonPath);

if (!featureCollection) {
throw new Error(`GeoJSON file not found: ${geoJsonPath}`);
}

// Build a map of feature ID -> feature for quick lookup
const featureMap = new Map<string, SafeFeature>();
for (const feature of featureCollection.features) {
const id = (feature as unknown as Record<string, unknown>).id as string | undefined;
const propsId = feature.properties?.['id'] as string | undefined;
const featureId = id ?? propsId;
if (featureId) {
featureMap.set(featureId, feature);
}
}

let updated = 0;
for (const { featureId, entry } of provenance) {
const feature = featureMap.get(featureId);
if (!feature) { continue; }

// Ensure properties exists
if (!feature.properties) {
(feature as unknown as Record<string, unknown>).properties = {};
}

const props = feature.properties!;
// Normalise provenance to array (FR-006: handle legacy single-object format)
let existing = props['provenance'];
if (existing === undefined || existing === null) {
existing = [];
} else if (!Array.isArray(existing)) {
existing = [existing];
}

// Append the new entry
(existing as unknown[]).push(entry);
props['provenance'] = existing;
updated++;
}

if (updated > 0) {
// Write updated GeoJSON
fs.writeFileSync(geoJsonPath, JSON.stringify(featureCollection, null, 2));

// Clear cache
this.itemCache.delete(fullItemPath);
}

return updated;
}

/**
* Load the GeoJSON FeatureCollection for a STAC item.
* Feature: 071-log-recording-service
*
* @param storePath Path to the STAC store root
* @param itemPath Relative path to the item JSON file
* @returns FeatureCollection or null
*/
async loadGeoJsonForItem(
storePath: string,
itemPath: string
): Promise<SafeFeatureCollection | null> {
const fullItemPath = path.join(storePath, itemPath);
const item = await this.loadItem(fullItemPath);

if (!item) { return null; }

const geoJsonAsset = Object.values(item.assets).find(
(asset) =>
asset.type === 'application/geo+json' ||
asset.href.endsWith('.geojson')
);

if (!geoJsonAsset) { return null; }

const itemDir = path.dirname(fullItemPath);
const geoJsonPath = path.resolve(itemDir, geoJsonAsset.href);
return this.loadGeoJson(geoJsonPath);
}

/**
* Check if an asset key already exists on a STAC item.
* Used for duplicate import detection.
Expand Down
16 changes: 16 additions & 0 deletions apps/vscode/src/types/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,17 @@ export interface ToolExecutionResult {

/** Artifact filename hint from debrief:href annotation */
artifactHref?: string;

/** Tool version from expanded contract (Phase 0) */
toolVersion?: string;
/** Modified features with property deltas (Phase 0) */
modifiedFeatures?: Array<{ featureId: string; changedProperties: Record<string, { previousValue: unknown; newValue: unknown }> }>;
/** Created feature IDs (Phase 0) */
createdFeatures?: string[];
/** Created artifacts (Phase 0) */
createdAssets?: Array<{ resultId: string; path: string; mimeType?: string }>;
/** Full resolved parameters (Phase 0) */
parameters?: Record<string, { value: unknown; default: boolean; tunable: boolean }>;
}

// ---------------------------------------------------------------------------
Expand All @@ -419,6 +430,11 @@ export interface DebriefAnnotations {
'debrief:label': string;
'debrief:href'?: string;
'debrief:deletedFeatures'?: string[];
'debrief:toolVersion'?: string;
'debrief:modifiedFeatures'?: Array<{ featureId: string; changedProperties: Record<string, { previousValue: unknown; newValue: unknown }> }>;
'debrief:createdFeatures'?: string[];
'debrief:createdAssets'?: Array<{ resultId: string; path: string; mimeType?: string }>;
'debrief:parameters'?: Record<string, { value: unknown; default: boolean; tunable: boolean }>;
}

/**
Expand Down
24 changes: 24 additions & 0 deletions services/session-state/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ export {
type LoadResult,
} from './persistence/index.js';

// Log Service (Feature: 071)
export {
buildLogEntry,
msToIsoDuration,
generateActivityId,
extractActivityIdFromOutputFeatures,
assembleTimeline,
type LogEntry,
type WasGeneratedBy,
type ParameterValue,
type TuneAnnotation,
type ExpandedToolResultFields,
type ModifiedFeature,
type PropertyDelta,
type CreatedAsset,
type RecordResult,
type TimelineOptions,
type ToolResultForLog,
type FeatureProvenance,
type LogService,
type LogServiceDeps,
createLogService,
} from './log/index.js';

// Server (for standalone mode)
export {
createApp,
Expand Down
Loading
Loading