@perceo/observer-engine 1.0.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 +155 -6
- package/dist/index.js +572 -7
- package/package.json +8 -3
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
|
|
104
|
-
* a
|
|
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
|
-
*
|
|
111
|
-
* managed Observer API
|
|
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
|
-
|
|
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
|
|
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
|
|
101
|
-
* a
|
|
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
|
-
*
|
|
123
|
-
* managed Observer API
|
|
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": "
|
|
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.
|
|
28
|
+
"@repo/typescript-config": "0.3.0"
|
|
24
29
|
},
|
|
25
30
|
"peerDependencies": {
|
|
26
|
-
"@perceo/perceo": "0.
|
|
31
|
+
"@perceo/perceo": "0.3.1"
|
|
27
32
|
},
|
|
28
33
|
"scripts": {
|
|
29
34
|
"build": "tsup src/index.ts --format esm --dts --clean",
|