@perceo/observer-engine 0.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ import { TestRun, Insight, Flow, FlowInsert, PerceoDataClient } from '@perceo/supabase';
2
+
3
+ interface TemporalConfig {
4
+ enabled: boolean;
5
+ address?: string;
6
+ namespace?: string;
7
+ taskQueue?: string;
8
+ apiKey?: string;
9
+ }
1
10
  interface ObserverEngineConfig {
2
11
  observer: {
3
12
  watch: {
@@ -30,6 +39,7 @@ interface ObserverEngineConfig {
30
39
  type: "in-memory" | "redis";
31
40
  redisUrl?: string;
32
41
  };
42
+ temporal?: TemporalConfig;
33
43
  }
34
44
  interface ChangeAnalysisFile {
35
45
  path: string;
@@ -96,26 +106,42 @@ declare class ObserverEngine {
96
106
  private readonly config;
97
107
  private readonly deps;
98
108
  private readonly apiClient;
109
+ private readonly temporalClient;
99
110
  constructor(config: ObserverEngineConfig, deps?: ObserverEngineDeps);
100
111
  /**
101
112
  * Bootstrap flows/personas for the current project.
102
113
  *
103
- * Delegates to the managed Observer API when configured, otherwise returns
104
- * a fast local no-op result so init remains non-blocking.
114
+ * Delegates to Temporal workflow if enabled, otherwise uses the managed
115
+ * Observer API directly. Returns a local no-op result if no API is configured.
105
116
  */
106
117
  bootstrapProject(options: BootstrapOptions): Promise<BootstrapResult>;
118
+ /**
119
+ * Bootstrap via Temporal workflow
120
+ */
121
+ private bootstrapProjectViaWorkflow;
122
+ /**
123
+ * Bootstrap via direct API call (original implementation)
124
+ */
125
+ private bootstrapProjectDirect;
107
126
  /**
108
127
  * Analyze changes between two Git refs and return an ImpactReport.
109
128
  *
110
- * Uses local Git diff to build a ChangeAnalysis, then delegates to the
111
- * managed Observer API when configured. If no API is configured, returns
112
- * a minimal, local-only report that still captures the changed files.
129
+ * Delegates to Temporal workflow if enabled, otherwise uses local Git diff
130
+ * and managed Observer API directly.
113
131
  */
114
132
  analyzeChanges(params: {
115
133
  baseSha: string;
116
134
  headSha: string;
117
135
  projectRoot: string;
118
136
  }): Promise<ImpactReport>;
137
+ /**
138
+ * Analyze changes via Temporal workflow
139
+ */
140
+ private analyzeChangesViaWorkflow;
141
+ /**
142
+ * Analyze changes via direct API call (original implementation)
143
+ */
144
+ private analyzeChangesDirect;
119
145
  /**
120
146
  * Core entry point for watch-mode integration.
121
147
  *
@@ -146,4 +172,127 @@ declare class ObserverApiClient {
146
172
  private post;
147
173
  }
148
174
 
149
- export { type BootstrapOptions, type BootstrapResult, type ChangeAnalysis, type ChangeAnalysisFile, type EventBusLike, type FlowGraphClientLike, type ImpactReport, type ImpactedFlow, ObserverApiClient, type ObserverApiClientOptions, ObserverEngine, type ObserverEngineConfig, type ObserverEngineDeps, type PerceoEvent };
175
+ declare function computeChangeAnalysis(projectRoot: string, baseSha: string, headSha: string): Promise<ChangeAnalysis>;
176
+
177
+ /**
178
+ * Supabase-Powered Observer Engine
179
+ *
180
+ * Implements the Observer Engine workflow using Supabase for:
181
+ * - Persistent storage of code changes, flows, and analysis results
182
+ * - Realtime event bus via Supabase Realtime (replaces Redis pub/sub)
183
+ * - File watching with change detection and flow impact analysis
184
+ */
185
+
186
+ interface SupabaseObserverConfig {
187
+ supabaseUrl: string;
188
+ supabaseKey: string;
189
+ projectId: string;
190
+ projectName: string;
191
+ watch?: {
192
+ paths: string[];
193
+ ignore?: string[];
194
+ debounceMs?: number;
195
+ };
196
+ analysis?: {
197
+ useLLM?: boolean;
198
+ llmThreshold?: number;
199
+ };
200
+ }
201
+ interface FileChange {
202
+ path: string;
203
+ type: "add" | "change" | "unlink";
204
+ timestamp: number;
205
+ }
206
+ interface ObserverCallbacks {
207
+ onFileChange?: (change: FileChange) => void | Promise<void>;
208
+ onAnalysisStart?: (changeId: string) => void | Promise<void>;
209
+ onAnalysisComplete?: (report: ImpactReport) => void | Promise<void>;
210
+ onFlowsAffected?: (flows: ImpactedFlow[]) => void | Promise<void>;
211
+ onTestRunUpdate?: (testRun: TestRun) => void | Promise<void>;
212
+ onInsightCreated?: (insight: Insight) => void | Promise<void>;
213
+ onError?: (error: Error) => void | Promise<void>;
214
+ }
215
+ declare class SupabaseObserverEngine {
216
+ private readonly config;
217
+ private readonly client;
218
+ private readonly callbacks;
219
+ private watcher;
220
+ private pendingChanges;
221
+ private debounceTimer;
222
+ private isAnalyzing;
223
+ constructor(config: SupabaseObserverConfig, callbacks?: ObserverCallbacks);
224
+ /**
225
+ * Create from observer engine config (backwards compatible)
226
+ */
227
+ static fromConfig(config: ObserverEngineConfig, projectId: string, projectName: string, callbacks?: ObserverCallbacks): SupabaseObserverEngine | null;
228
+ /**
229
+ * Start watching for file changes
230
+ */
231
+ startWatch(projectRoot: string): Promise<void>;
232
+ /**
233
+ * Stop watching for file changes
234
+ */
235
+ stopWatch(): Promise<void>;
236
+ /**
237
+ * Set up Supabase Realtime subscriptions
238
+ */
239
+ private setupRealtimeSubscriptions;
240
+ /**
241
+ * Handle individual file change
242
+ */
243
+ private handleFileChange;
244
+ /**
245
+ * Process accumulated changes
246
+ */
247
+ private processChanges;
248
+ /**
249
+ * Match flows affected by changed files
250
+ */
251
+ private matchAffectedFlows;
252
+ /**
253
+ * Check if a flow is affected by changed files
254
+ */
255
+ private doesFlowMatchChanges;
256
+ /**
257
+ * Check if a file path matches a pattern
258
+ */
259
+ private pathMatchesPattern;
260
+ /**
261
+ * Calculate aggregate risk score from affected flows
262
+ */
263
+ private calculateRiskScore;
264
+ /**
265
+ * Get risk level from score
266
+ */
267
+ private getRiskLevel;
268
+ /**
269
+ * Get current HEAD commit SHA
270
+ */
271
+ private getCurrentHead;
272
+ /**
273
+ * Manually analyze changes between two commits
274
+ */
275
+ analyzeChanges(projectRoot: string, baseSha: string, headSha: string): Promise<ImpactReport>;
276
+ /**
277
+ * Get all flows for the project
278
+ */
279
+ getFlows(): Promise<Flow[]>;
280
+ /**
281
+ * Get flows affected by recent changes
282
+ */
283
+ getAffectedFlows(): Promise<Flow[]>;
284
+ /**
285
+ * Create or update flows (bootstrap)
286
+ */
287
+ upsertFlows(flows: FlowInsert[]): Promise<Flow[]>;
288
+ /**
289
+ * Clear affected status from flows (e.g., after tests pass)
290
+ */
291
+ clearAffectedFlows(flowIds: string[]): Promise<void>;
292
+ /**
293
+ * Get the data client for direct access
294
+ */
295
+ getDataClient(): PerceoDataClient;
296
+ }
297
+
298
+ export { type BootstrapOptions, type BootstrapResult, type ChangeAnalysis, type ChangeAnalysisFile, type EventBusLike, type FileChange, type FlowGraphClientLike, type ImpactReport, type ImpactedFlow, ObserverApiClient, type ObserverApiClientOptions, type ObserverCallbacks, ObserverEngine, type ObserverEngineConfig, type ObserverEngineDeps, type PerceoEvent, type SupabaseObserverConfig, SupabaseObserverEngine, type TemporalConfig, computeChangeAnalysis };
package/dist/index.js CHANGED
@@ -52,7 +52,9 @@ async function computeChangeAnalysis(projectRoot, baseSha, headSha) {
52
52
  cwd: projectRoot
53
53
  });
54
54
  const files = stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
55
- const [status, filePath] = line.split(/\s+/, 2);
55
+ const parts = line.split(/\s+/);
56
+ const status = parts[0] ?? "M";
57
+ const filePath = parts[1] ?? "";
56
58
  return {
57
59
  path: normalizePath(filePath),
58
60
  status: mapStatus(status)
@@ -84,23 +86,144 @@ function normalizePath(p) {
84
86
  return p.split("\\").join("/");
85
87
  }
86
88
 
89
+ // src/temporal-client.ts
90
+ import { Client, Connection } from "@temporalio/client";
91
+ var TemporalClient = class {
92
+ config;
93
+ clientPromise = null;
94
+ constructor(config) {
95
+ this.config = config;
96
+ }
97
+ /**
98
+ * Gets or creates the Temporal client
99
+ */
100
+ async getClient() {
101
+ if (!this.clientPromise) {
102
+ this.clientPromise = this.createClient();
103
+ }
104
+ return this.clientPromise;
105
+ }
106
+ /**
107
+ * Creates a new Temporal client with connection
108
+ */
109
+ async createClient() {
110
+ const address = this.config.address || "localhost:7233";
111
+ const namespace = this.config.namespace || "perceo";
112
+ const connection = await Connection.connect({
113
+ address,
114
+ tls: true,
115
+ apiKey: this.config.apiKey
116
+ });
117
+ return new Client({
118
+ connection,
119
+ namespace
120
+ });
121
+ }
122
+ /**
123
+ * Executes a workflow and waits for result
124
+ */
125
+ async executeWorkflow(workflowName, input, options) {
126
+ const client = await this.getClient();
127
+ const taskQueue = options.taskQueue || this.config.taskQueue || "observer-engine";
128
+ const handle = await client.workflow.start(workflowName, {
129
+ args: [input],
130
+ taskQueue,
131
+ workflowId: options.workflowId
132
+ });
133
+ return await handle.result();
134
+ }
135
+ /**
136
+ * Starts a workflow without waiting for result
137
+ */
138
+ async startWorkflow(workflowName, input, options) {
139
+ const client = await this.getClient();
140
+ const taskQueue = options.taskQueue || this.config.taskQueue || "observer-engine";
141
+ return await client.workflow.start(workflowName, {
142
+ args: [input],
143
+ taskQueue,
144
+ workflowId: options.workflowId
145
+ });
146
+ }
147
+ /**
148
+ * Gets a handle to an existing workflow
149
+ */
150
+ async getWorkflowHandle(workflowId) {
151
+ const client = await this.getClient();
152
+ return client.workflow.getHandle(workflowId);
153
+ }
154
+ /**
155
+ * Signals a running workflow
156
+ */
157
+ async signalWorkflow(workflowId, signalName, args) {
158
+ const handle = await this.getWorkflowHandle(workflowId);
159
+ await handle.signal(signalName, ...args || []);
160
+ }
161
+ /**
162
+ * Queries a running workflow
163
+ */
164
+ async queryWorkflow(workflowId, queryName) {
165
+ const handle = await this.getWorkflowHandle(workflowId);
166
+ return await handle.query(queryName);
167
+ }
168
+ };
169
+
87
170
  // src/engine.ts
88
171
  var ObserverEngine = class {
89
172
  config;
90
173
  deps;
91
174
  apiClient;
175
+ temporalClient;
92
176
  constructor(config, deps = {}) {
93
177
  this.config = config;
94
178
  this.deps = deps;
95
179
  this.apiClient = ObserverApiClient.fromConfig(config);
180
+ this.temporalClient = config.temporal?.enabled ? new TemporalClient(config.temporal) : null;
96
181
  }
97
182
  /**
98
183
  * Bootstrap flows/personas for the current project.
99
184
  *
100
- * Delegates to the managed Observer API when configured, otherwise returns
101
- * a fast local no-op result so init remains non-blocking.
185
+ * Delegates to Temporal workflow if enabled, otherwise uses the managed
186
+ * Observer API directly. Returns a local no-op result if no API is configured.
102
187
  */
103
188
  async bootstrapProject(options) {
189
+ if (this.temporalClient) {
190
+ return this.bootstrapProjectViaWorkflow(options);
191
+ }
192
+ return this.bootstrapProjectDirect(options);
193
+ }
194
+ /**
195
+ * Bootstrap via Temporal workflow
196
+ */
197
+ async bootstrapProjectViaWorkflow(options) {
198
+ if (!this.config.observer.apiBaseUrl) {
199
+ throw new Error("Observer API base URL is required for Temporal workflows");
200
+ }
201
+ const workflowInput = {
202
+ projectDir: options.projectDir,
203
+ projectName: options.projectName,
204
+ framework: options.framework,
205
+ apiConfig: {
206
+ baseUrl: this.config.observer.apiBaseUrl,
207
+ apiKey: this.config.observer.apiKey
208
+ },
209
+ eventBusConfig: this.config.eventBus?.type === "redis" ? {
210
+ redisUrl: this.config.eventBus.redisUrl || process.env.PERCEO_REDIS_URL || ""
211
+ } : void 0
212
+ };
213
+ const result = await this.temporalClient.executeWorkflow("bootstrapProjectWorkflow", workflowInput, {
214
+ workflowId: `bootstrap-${options.projectName}-${Date.now()}`
215
+ });
216
+ return {
217
+ projectName: result.projectName || options.projectName,
218
+ framework: result.framework,
219
+ flowsInitialized: result.flows?.length || 0,
220
+ personasInitialized: result.personas?.length || 0
221
+ };
222
+ }
223
+ /**
224
+ * Bootstrap via direct API call (original implementation)
225
+ */
226
+ async bootstrapProjectDirect(options) {
104
227
  if (!this.apiClient) {
105
228
  return {
106
229
  projectName: options.projectName,
@@ -119,11 +242,57 @@ var ObserverEngine = class {
119
242
  /**
120
243
  * Analyze changes between two Git refs and return an ImpactReport.
121
244
  *
122
- * Uses local Git diff to build a ChangeAnalysis, then delegates to the
123
- * managed Observer API when configured. If no API is configured, returns
124
- * a minimal, local-only report that still captures the changed files.
245
+ * Delegates to Temporal workflow if enabled, otherwise uses local Git diff
246
+ * and managed Observer API directly.
125
247
  */
126
248
  async analyzeChanges(params) {
249
+ if (this.temporalClient) {
250
+ return this.analyzeChangesViaWorkflow(params);
251
+ }
252
+ return this.analyzeChangesDirect(params);
253
+ }
254
+ /**
255
+ * Analyze changes via Temporal workflow
256
+ */
257
+ async analyzeChangesViaWorkflow(params) {
258
+ if (!this.config.observer.apiBaseUrl) {
259
+ throw new Error("Observer API base URL is required for Temporal workflows");
260
+ }
261
+ const workflowInput = {
262
+ projectId: "default",
263
+ // TODO: Get from config or context
264
+ projectRoot: params.projectRoot,
265
+ baseSha: params.baseSha,
266
+ headSha: params.headSha,
267
+ apiConfig: {
268
+ baseUrl: this.config.observer.apiBaseUrl,
269
+ apiKey: this.config.observer.apiKey
270
+ },
271
+ eventBusConfig: this.config.eventBus?.type === "redis" ? {
272
+ redisUrl: this.config.eventBus.redisUrl || process.env.PERCEO_REDIS_URL || ""
273
+ } : void 0
274
+ };
275
+ const result = await this.temporalClient.executeWorkflow("analyzeChangesWorkflow", workflowInput, {
276
+ workflowId: `analyze-${params.baseSha}-${params.headSha}-${Date.now()}`
277
+ });
278
+ return {
279
+ changeId: `${params.baseSha}...${params.headSha}`,
280
+ baseSha: params.baseSha,
281
+ headSha: params.headSha,
282
+ flows: result.affectedFlows.map((flowId) => ({
283
+ name: flowId,
284
+ confidence: 0.8,
285
+ riskScore: result.riskLevel === "high" ? 0.8 : result.riskLevel === "medium" ? 0.5 : 0.2
286
+ })),
287
+ changes: [],
288
+ // Changes already analyzed by workflow
289
+ createdAt: Date.now()
290
+ };
291
+ }
292
+ /**
293
+ * Analyze changes via direct API call (original implementation)
294
+ */
295
+ async analyzeChangesDirect(params) {
127
296
  const change = await computeChangeAnalysis(params.projectRoot, params.baseSha, params.headSha);
128
297
  let report;
129
298
  if (this.apiClient) {
@@ -163,7 +332,403 @@ var ObserverEngine = class {
163
332
  };
164
333
  }
165
334
  };
335
+
336
+ // src/supabase-observer.ts
337
+ import chokidar from "chokidar";
338
+ import { PerceoDataClient } from "@perceo/supabase";
339
+ var SupabaseObserverEngine = class _SupabaseObserverEngine {
340
+ config;
341
+ client;
342
+ callbacks;
343
+ watcher = null;
344
+ pendingChanges = /* @__PURE__ */ new Map();
345
+ debounceTimer = null;
346
+ isAnalyzing = false;
347
+ constructor(config, callbacks = {}) {
348
+ this.config = config;
349
+ this.callbacks = callbacks;
350
+ this.client = new PerceoDataClient({
351
+ supabaseUrl: config.supabaseUrl,
352
+ supabaseKey: config.supabaseKey,
353
+ projectId: config.projectId
354
+ });
355
+ }
356
+ /**
357
+ * Create from observer engine config (backwards compatible)
358
+ */
359
+ static fromConfig(config, projectId, projectName, callbacks) {
360
+ const supabaseUrl = process.env.PERCEO_SUPABASE_URL;
361
+ const supabaseKey = process.env.PERCEO_SUPABASE_SERVICE_ROLE_KEY || process.env.PERCEO_SUPABASE_ANON_KEY;
362
+ if (!supabaseUrl || !supabaseKey) {
363
+ return null;
364
+ }
365
+ return new _SupabaseObserverEngine(
366
+ {
367
+ supabaseUrl,
368
+ supabaseKey,
369
+ projectId,
370
+ projectName,
371
+ watch: config.observer.watch,
372
+ analysis: config.observer.analysis
373
+ },
374
+ callbacks
375
+ );
376
+ }
377
+ /**
378
+ * Start watching for file changes
379
+ */
380
+ async startWatch(projectRoot) {
381
+ const watchPaths = this.config.watch?.paths ?? ["src/", "app/", "pages/", "components/"];
382
+ const ignorePaths = this.config.watch?.ignore ?? ["node_modules/", ".next/", ".git/", "dist/"];
383
+ const debounceMs = this.config.watch?.debounceMs ?? 500;
384
+ await this.setupRealtimeSubscriptions();
385
+ this.watcher = chokidar.watch(
386
+ watchPaths.map((p) => `${projectRoot}/${p}`),
387
+ {
388
+ ignored: ignorePaths.map((p) => `**/${p}**`),
389
+ ignoreInitial: true,
390
+ persistent: true,
391
+ awaitWriteFinish: {
392
+ stabilityThreshold: 100,
393
+ pollInterval: 50
394
+ }
395
+ }
396
+ );
397
+ this.watcher.on("add", (path) => this.handleFileChange(path, "add"));
398
+ this.watcher.on("change", (path) => this.handleFileChange(path, "change"));
399
+ this.watcher.on("unlink", (path) => this.handleFileChange(path, "unlink"));
400
+ this.watcher.on("error", (error) => {
401
+ this.callbacks.onError?.(error);
402
+ });
403
+ this.watcher.on("all", () => {
404
+ if (this.debounceTimer) {
405
+ clearTimeout(this.debounceTimer);
406
+ }
407
+ this.debounceTimer = setTimeout(() => {
408
+ this.processChanges(projectRoot);
409
+ }, debounceMs);
410
+ });
411
+ }
412
+ /**
413
+ * Stop watching for file changes
414
+ */
415
+ async stopWatch() {
416
+ if (this.debounceTimer) {
417
+ clearTimeout(this.debounceTimer);
418
+ this.debounceTimer = null;
419
+ }
420
+ if (this.watcher) {
421
+ await this.watcher.close();
422
+ this.watcher = null;
423
+ }
424
+ await this.client.cleanup();
425
+ }
426
+ /**
427
+ * Set up Supabase Realtime subscriptions
428
+ */
429
+ async setupRealtimeSubscriptions() {
430
+ const projectId = this.config.projectId;
431
+ this.client.subscribeToTestRuns(projectId, (payload) => {
432
+ if (payload.eventType === "INSERT" || payload.eventType === "UPDATE") {
433
+ this.callbacks.onTestRunUpdate?.(payload.new);
434
+ }
435
+ });
436
+ this.client.subscribeToInsights(projectId, (payload) => {
437
+ if (payload.eventType === "INSERT") {
438
+ this.callbacks.onInsightCreated?.(payload.new);
439
+ }
440
+ });
441
+ this.client.subscribeToFlows(projectId, (payload) => {
442
+ if (payload.eventType === "UPDATE" && payload.new.affected_by_changes?.length > 0) {
443
+ const impactedFlow = {
444
+ name: payload.new.name,
445
+ confidence: 0.8,
446
+ riskScore: payload.new.risk_score,
447
+ priority: payload.new.priority
448
+ };
449
+ this.callbacks.onFlowsAffected?.([impactedFlow]);
450
+ }
451
+ });
452
+ }
453
+ /**
454
+ * Handle individual file change
455
+ */
456
+ handleFileChange(path, type) {
457
+ const change = {
458
+ path,
459
+ type,
460
+ timestamp: Date.now()
461
+ };
462
+ this.pendingChanges.set(path, change);
463
+ this.callbacks.onFileChange?.(change);
464
+ }
465
+ /**
466
+ * Process accumulated changes
467
+ */
468
+ async processChanges(projectRoot) {
469
+ if (this.isAnalyzing || this.pendingChanges.size === 0) {
470
+ return;
471
+ }
472
+ this.isAnalyzing = true;
473
+ const changes = Array.from(this.pendingChanges.values());
474
+ this.pendingChanges.clear();
475
+ try {
476
+ const headSha = await this.getCurrentHead(projectRoot);
477
+ const baseSha = `${headSha}~1`;
478
+ const changeId = `change-${Date.now()}`;
479
+ this.callbacks.onAnalysisStart?.(changeId);
480
+ const analysis = await computeChangeAnalysis(projectRoot, baseSha, headSha);
481
+ const codeChange = await this.client.createCodeChange({
482
+ project_id: this.config.projectId,
483
+ base_sha: baseSha,
484
+ head_sha: headSha,
485
+ files: analysis.files.map((f) => ({
486
+ path: f.path,
487
+ status: f.status
488
+ }))
489
+ });
490
+ const flows = await this.client.getFlows(this.config.projectId);
491
+ const affectedFlows = await this.matchAffectedFlows(flows, analysis.files);
492
+ const riskScore = this.calculateRiskScore(affectedFlows);
493
+ const riskLevel = this.getRiskLevel(riskScore);
494
+ await this.client.updateCodeChangeAnalysis(codeChange.id, {
495
+ risk_level: riskLevel,
496
+ risk_score: riskScore,
497
+ affected_flow_ids: affectedFlows.map((f) => f.id)
498
+ });
499
+ await this.client.markFlowsAffected(
500
+ affectedFlows.map((f) => f.id),
501
+ codeChange.id,
502
+ riskScore * 0.2
503
+ );
504
+ await this.client.publishFlowsAffected(
505
+ codeChange.id,
506
+ affectedFlows.map((f) => ({
507
+ id: f.id,
508
+ name: f.name,
509
+ riskScore: f.risk_score
510
+ })),
511
+ "observer"
512
+ );
513
+ const impactReport = {
514
+ changeId: codeChange.id,
515
+ baseSha,
516
+ headSha,
517
+ flows: affectedFlows.map((f) => ({
518
+ name: f.name,
519
+ confidence: 0.8,
520
+ // TODO: Use LLM for better confidence
521
+ riskScore: f.risk_score,
522
+ priority: f.priority
523
+ })),
524
+ changes: analysis.files,
525
+ createdAt: Date.now()
526
+ };
527
+ this.callbacks.onAnalysisComplete?.(impactReport);
528
+ this.callbacks.onFlowsAffected?.(impactReport.flows);
529
+ } catch (error) {
530
+ this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
531
+ } finally {
532
+ this.isAnalyzing = false;
533
+ }
534
+ }
535
+ /**
536
+ * Match flows affected by changed files
537
+ */
538
+ async matchAffectedFlows(flows, changedFiles) {
539
+ const affectedFlows = [];
540
+ for (const flow of flows) {
541
+ const isAffected = this.doesFlowMatchChanges(flow, changedFiles);
542
+ if (isAffected) {
543
+ affectedFlows.push(flow);
544
+ }
545
+ }
546
+ return affectedFlows;
547
+ }
548
+ /**
549
+ * Check if a flow is affected by changed files
550
+ */
551
+ doesFlowMatchChanges(flow, changedFiles) {
552
+ if (flow.entry_point) {
553
+ for (const file of changedFiles) {
554
+ if (this.pathMatchesPattern(file.path, flow.entry_point)) {
555
+ return true;
556
+ }
557
+ }
558
+ }
559
+ const graphData = flow.graph_data;
560
+ if (graphData) {
561
+ const patterns = [
562
+ ...graphData.components ?? [],
563
+ ...graphData.pages ?? [],
564
+ ...graphData.dependencies ?? []
565
+ ];
566
+ for (const pattern of patterns) {
567
+ for (const file of changedFiles) {
568
+ if (this.pathMatchesPattern(file.path, pattern)) {
569
+ return true;
570
+ }
571
+ }
572
+ }
573
+ }
574
+ const flowKeywords = flow.name.toLowerCase().split(/[\s-_]+/);
575
+ for (const file of changedFiles) {
576
+ const filePath = file.path.toLowerCase();
577
+ for (const keyword of flowKeywords) {
578
+ if (filePath.includes(keyword) && keyword.length > 3) {
579
+ return true;
580
+ }
581
+ }
582
+ }
583
+ return false;
584
+ }
585
+ /**
586
+ * Check if a file path matches a pattern
587
+ */
588
+ pathMatchesPattern(filePath, pattern) {
589
+ const normalizedPath = filePath.toLowerCase();
590
+ const normalizedPattern = pattern.toLowerCase();
591
+ if (normalizedPath.includes(normalizedPattern)) {
592
+ return true;
593
+ }
594
+ const fileName = filePath.split("/").pop()?.replace(/\.[^/.]+$/, "") ?? "";
595
+ if (fileName.toLowerCase() === normalizedPattern.replace(/\.[^/.]+$/, "").toLowerCase()) {
596
+ return true;
597
+ }
598
+ return false;
599
+ }
600
+ /**
601
+ * Calculate aggregate risk score from affected flows
602
+ */
603
+ calculateRiskScore(flows) {
604
+ if (flows.length === 0) return 0;
605
+ const priorityWeights = {
606
+ critical: 1,
607
+ high: 0.75,
608
+ medium: 0.5,
609
+ low: 0.25
610
+ };
611
+ let totalWeight = 0;
612
+ let weightedScore = 0;
613
+ for (const flow of flows) {
614
+ const weight = priorityWeights[flow.priority ?? "medium"] ?? 0.5;
615
+ totalWeight += weight;
616
+ weightedScore += weight * (flow.risk_score + 0.3);
617
+ }
618
+ return Math.min(1, weightedScore / Math.max(totalWeight, 1));
619
+ }
620
+ /**
621
+ * Get risk level from score
622
+ */
623
+ getRiskLevel(score) {
624
+ if (score >= 0.8) return "critical";
625
+ if (score >= 0.6) return "high";
626
+ if (score >= 0.3) return "medium";
627
+ return "low";
628
+ }
629
+ /**
630
+ * Get current HEAD commit SHA
631
+ */
632
+ async getCurrentHead(projectRoot) {
633
+ const { execSync } = await import("child_process");
634
+ try {
635
+ return execSync("git rev-parse HEAD", { cwd: projectRoot, encoding: "utf-8" }).trim();
636
+ } catch {
637
+ return "HEAD";
638
+ }
639
+ }
640
+ // ==========================================================================
641
+ // Public API Methods
642
+ // ==========================================================================
643
+ /**
644
+ * Manually analyze changes between two commits
645
+ */
646
+ async analyzeChanges(projectRoot, baseSha, headSha) {
647
+ const analysis = await computeChangeAnalysis(projectRoot, baseSha, headSha);
648
+ const codeChange = await this.client.createCodeChange({
649
+ project_id: this.config.projectId,
650
+ base_sha: baseSha,
651
+ head_sha: headSha,
652
+ files: analysis.files.map((f) => ({
653
+ path: f.path,
654
+ status: f.status
655
+ }))
656
+ });
657
+ const flows = await this.client.getFlows(this.config.projectId);
658
+ const affectedFlows = await this.matchAffectedFlows(flows, analysis.files);
659
+ const riskScore = this.calculateRiskScore(affectedFlows);
660
+ await this.client.updateCodeChangeAnalysis(codeChange.id, {
661
+ risk_level: this.getRiskLevel(riskScore),
662
+ risk_score: riskScore,
663
+ affected_flow_ids: affectedFlows.map((f) => f.id)
664
+ });
665
+ await this.client.markFlowsAffected(
666
+ affectedFlows.map((f) => f.id),
667
+ codeChange.id,
668
+ riskScore * 0.2
669
+ );
670
+ await this.client.publishFlowsAffected(
671
+ codeChange.id,
672
+ affectedFlows.map((f) => ({
673
+ id: f.id,
674
+ name: f.name,
675
+ riskScore: f.risk_score
676
+ })),
677
+ "observer"
678
+ );
679
+ return {
680
+ changeId: codeChange.id,
681
+ baseSha,
682
+ headSha,
683
+ flows: affectedFlows.map((f) => ({
684
+ name: f.name,
685
+ confidence: 0.8,
686
+ riskScore: f.risk_score,
687
+ priority: f.priority
688
+ })),
689
+ changes: analysis.files,
690
+ createdAt: Date.now()
691
+ };
692
+ }
693
+ /**
694
+ * Get all flows for the project
695
+ */
696
+ async getFlows() {
697
+ return this.client.getFlows(this.config.projectId);
698
+ }
699
+ /**
700
+ * Get flows affected by recent changes
701
+ */
702
+ async getAffectedFlows() {
703
+ return this.client.getAffectedFlows(this.config.projectId);
704
+ }
705
+ /**
706
+ * Create or update flows (bootstrap)
707
+ */
708
+ async upsertFlows(flows) {
709
+ return this.client.upsertFlows(
710
+ flows.map((f) => ({
711
+ ...f,
712
+ project_id: this.config.projectId
713
+ }))
714
+ );
715
+ }
716
+ /**
717
+ * Clear affected status from flows (e.g., after tests pass)
718
+ */
719
+ async clearAffectedFlows(flowIds) {
720
+ await this.client.clearAffectedFlows(flowIds);
721
+ }
722
+ /**
723
+ * Get the data client for direct access
724
+ */
725
+ getDataClient() {
726
+ return this.client;
727
+ }
728
+ };
166
729
  export {
167
730
  ObserverApiClient,
168
- ObserverEngine
731
+ ObserverEngine,
732
+ SupabaseObserverEngine,
733
+ computeChangeAnalysis
169
734
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perceo/observer-engine",
3
- "version": "0.1.0",
3
+ "version": "2.0.1",
4
4
  "description": "Observer Engine core used by the Perceo CLI for flow/persona bootstrap and change impact analysis.",
5
5
  "keywords": [
6
6
  "perceo",
@@ -16,14 +16,19 @@
16
16
  "files": [
17
17
  "dist"
18
18
  ],
19
+ "dependencies": {
20
+ "@temporalio/client": "^1.11.5",
21
+ "chokidar": "^3.5.3",
22
+ "@perceo/supabase": "0.2.0"
23
+ },
19
24
  "devDependencies": {
20
25
  "@types/node": "^22.15.3",
21
26
  "tsup": "^8.0.1",
22
27
  "typescript": "5.9.2",
23
- "@repo/typescript-config": "0.1.0"
28
+ "@repo/typescript-config": "0.3.0"
24
29
  },
25
30
  "peerDependencies": {
26
- "@perceo/perceo": "0.x"
31
+ "@perceo/perceo": "0.3.1"
27
32
  },
28
33
  "scripts": {
29
34
  "build": "tsup src/index.ts --format esm --dts --clean",