@guanlin4924/devflow-pi 0.1.73 → 0.1.74
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/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import sqlite3
|
|
|
9
9
|
import subprocess
|
|
10
10
|
import sys
|
|
11
11
|
import tempfile
|
|
12
|
+
import time
|
|
12
13
|
import uuid
|
|
13
14
|
from datetime import datetime, timezone
|
|
14
15
|
from pathlib import Path
|
|
@@ -155,6 +156,29 @@ def _hash_lines(values) -> str:
|
|
|
155
156
|
return f"uuid5:{digest}"
|
|
156
157
|
|
|
157
158
|
|
|
159
|
+
class _Progress:
|
|
160
|
+
"""Emit human-readable progress immediately and retain a machine-readable trail."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, output: Path):
|
|
163
|
+
self.output = output
|
|
164
|
+
self.output.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
self.path = output / "scan-progress.jsonl"
|
|
166
|
+
self.started = time.monotonic()
|
|
167
|
+
|
|
168
|
+
def emit(self, phase: str, message: str, **details) -> None:
|
|
169
|
+
event = {
|
|
170
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
171
|
+
"elapsedSeconds": round(time.monotonic() - self.started, 3),
|
|
172
|
+
"phase": phase,
|
|
173
|
+
"message": message,
|
|
174
|
+
**details,
|
|
175
|
+
}
|
|
176
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
177
|
+
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
178
|
+
handle.flush()
|
|
179
|
+
print(f"[code-index][{phase}] {message}", file=sys.stderr, flush=True)
|
|
180
|
+
|
|
181
|
+
|
|
158
182
|
def main(argv=None) -> int:
|
|
159
183
|
parser = argparse.ArgumentParser(description="Build and validate a generic code intelligence index.")
|
|
160
184
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -215,7 +239,10 @@ def main(argv=None) -> int:
|
|
|
215
239
|
def command_scan(args) -> int:
|
|
216
240
|
root = Path(args.root).resolve()
|
|
217
241
|
output = (root / args.output).resolve() if not Path(args.output).is_absolute() else Path(args.output).resolve()
|
|
242
|
+
progress = _Progress(output)
|
|
243
|
+
progress.emit("start", "scan started", root=str(root), output=str(output), codebases=args.codebase)
|
|
218
244
|
with _index_lock(output, exclusive=True):
|
|
245
|
+
progress.emit("lock", "exclusive index lock acquired")
|
|
219
246
|
codebases = discover_codebases(root, args.codebase)
|
|
220
247
|
registry = AdapterRegistry()
|
|
221
248
|
changed = _normalize_changed_files(root, args.changed_files)
|
|
@@ -233,12 +260,15 @@ def command_scan(args) -> int:
|
|
|
233
260
|
if not codebase.root.exists():
|
|
234
261
|
diagnostics.append(_diagnostic(codebase.name, "", 1, "warning", "skipped_file", f"codebase root does not exist: {codebase.root}", "discovery@1"))
|
|
235
262
|
continue
|
|
263
|
+
progress.emit("discover", f"discovering {codebase.name}", codebase=codebase.name, path=str(codebase.root))
|
|
236
264
|
all_files = scan_files(codebase.root, excludes=args.exclude)
|
|
265
|
+
progress.emit("discover", f"discovered {len(all_files)} files in {codebase.name}", codebase=codebase.name, files=len(all_files))
|
|
237
266
|
discovered_paths.update(_workspace_rel(root, path) for path in all_files)
|
|
238
267
|
if changed:
|
|
239
268
|
all_files = [path for path in all_files if _workspace_rel(root, path) in changed]
|
|
240
269
|
manifests = [path for path in all_files if path.name in {"package.json", "go.mod", "Cargo.toml", "pyproject.toml", "requirements.txt", "composer.json", "pubspec.yaml"}]
|
|
241
270
|
adapters = registry.adapters_for(codebase, all_files, manifests)
|
|
271
|
+
parsed = 0
|
|
242
272
|
for path in all_files:
|
|
243
273
|
rel_path = _workspace_rel(root, path)
|
|
244
274
|
if rel_path in indexed_paths:
|
|
@@ -266,11 +296,17 @@ def command_scan(args) -> int:
|
|
|
266
296
|
diagnostics.extend(result.diagnostics)
|
|
267
297
|
adapter_entities.extend(getattr(result, "entities", []) or [])
|
|
268
298
|
adapter_flows.extend(getattr(result, "flows", []) or [])
|
|
299
|
+
parsed += 1
|
|
300
|
+
if parsed == 1 or parsed % 250 == 0 or parsed == len(all_files):
|
|
301
|
+
progress.emit("parse", f"parsed {parsed}/{len(all_files)} files in {codebase.name}", codebase=codebase.name, parsed=parsed, total=len(all_files))
|
|
269
302
|
except Exception as exc: # keep indexing useful even when one file fails
|
|
270
303
|
diagnostics.append(_diagnostic(codebase.name, rel_path, 1, "error", "parse_failed", str(exc), adapter.parser_id))
|
|
304
|
+
parsed += 1
|
|
271
305
|
|
|
306
|
+
progress.emit("relations", "resolving relations", files=len(files), symbols=len(symbols), relations=len(relations))
|
|
272
307
|
relations = _resolve_relations(symbols, relations)
|
|
273
308
|
files, symbols, relations, diagnostics = _dedupe(files, symbols, relations, diagnostics)
|
|
309
|
+
progress.emit("validate", "validating extracted facts", files=len(files), symbols=len(symbols), relations=len(relations), diagnostics=len(diagnostics))
|
|
274
310
|
errors = validate_index(files, symbols, relations, diagnostics)
|
|
275
311
|
if errors:
|
|
276
312
|
for error in errors[:200]:
|
|
@@ -285,6 +321,7 @@ def command_scan(args) -> int:
|
|
|
285
321
|
workspace_state["generatedAt"] = datetime.now(timezone.utc).isoformat()
|
|
286
322
|
workspace_state["semanticExtractor"] = SEMANTIC_EXTRACTOR_VERSION
|
|
287
323
|
workspace_state["extractorSetHash"] = _hash_lines([*workspace_state.get("extractors", []), SEMANTIC_EXTRACTOR_VERSION])
|
|
324
|
+
progress.emit("semantic", "building semantic facts")
|
|
288
325
|
semantic_entities, semantic_flows, feature_bindings = build_semantic_facts(root, files, symbols, relations)
|
|
289
326
|
semantic_entities.extend(adapter_entities)
|
|
290
327
|
semantic_flows.extend(adapter_flows)
|
|
@@ -292,6 +329,7 @@ def command_scan(args) -> int:
|
|
|
292
329
|
write_jsonl(staging / INDEX_FILES["symbols"], symbols)
|
|
293
330
|
write_jsonl(staging / INDEX_FILES["relations"], relations)
|
|
294
331
|
write_jsonl(staging / INDEX_FILES["diagnostics"], diagnostics)
|
|
332
|
+
progress.emit("sqlite", "writing sqlite index")
|
|
295
333
|
write_sqlite(
|
|
296
334
|
staging / "index.sqlite", files, symbols, relations, diagnostics,
|
|
297
335
|
snapshot_id=generation,
|
|
@@ -300,6 +338,7 @@ def command_scan(args) -> int:
|
|
|
300
338
|
semantic_flows=semantic_flows,
|
|
301
339
|
feature_bindings=feature_bindings,
|
|
302
340
|
)
|
|
341
|
+
progress.emit("documents", "writing index projections")
|
|
303
342
|
write_markdown(staging, files, symbols, relations, diagnostics)
|
|
304
343
|
staged_errors = validate_index(*_load_tuple(staging))
|
|
305
344
|
if staged_errors:
|
|
@@ -307,6 +346,7 @@ def command_scan(args) -> int:
|
|
|
307
346
|
print(error, file=sys.stderr)
|
|
308
347
|
print("Staged index validation failed; the previous snapshot was preserved.", file=sys.stderr)
|
|
309
348
|
return 1
|
|
349
|
+
progress.emit("publish", "publishing immutable snapshot", generation=generation)
|
|
310
350
|
_publish_snapshot(
|
|
311
351
|
staging,
|
|
312
352
|
output,
|
|
@@ -322,6 +362,7 @@ def command_scan(args) -> int:
|
|
|
322
362
|
finally:
|
|
323
363
|
shutil.rmtree(staging, ignore_errors=True)
|
|
324
364
|
|
|
365
|
+
progress.emit("complete", "scan completed", files=len(files), symbols=len(symbols), relations=len(relations), diagnostics=len(diagnostics))
|
|
325
366
|
print(f"Indexed {len(files)} files, {len(symbols)} symbols, {len(relations)} relations, {len(diagnostics)} diagnostics.")
|
|
326
367
|
print(f"Output: {output}")
|
|
327
368
|
return 0
|
package/src/constants.ts
CHANGED
|
@@ -383,12 +383,27 @@ export class InitializationStore {
|
|
|
383
383
|
return { id: prior.id, leaseToken: prior.leaseToken, reused: true };
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
|
+
const active = this.db.query(`SELECT id FROM initialization_runs
|
|
387
|
+
WHERE workspace_id=? AND config_id=?
|
|
388
|
+
AND status IN ('preparing','collecting','validating')
|
|
389
|
+
AND lease_expires_at>? LIMIT 1`).get(input.workspaceId, input.configId, now()) as { id: string } | null;
|
|
390
|
+
if (active) throw new Error(`Initialization already running for workspace/config: ${active.id}`);
|
|
386
391
|
const id = randomUUID(); const leaseToken = randomUUID(); const timestamp = now();
|
|
387
392
|
const expires = new Date(Date.now() + (input.leaseMs ?? 15 * 60_000)).toISOString();
|
|
388
393
|
this.db.query("INSERT INTO initialization_runs(id,project_id,workspace_id,config_id,input_snapshot_id,request_id,request_hash,status,lease_token,lease_expires_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)").run(id, this.context.manifest.projectId, input.workspaceId, input.configId, input.inputSnapshotId, input.requestId ?? null, requestHash, "collecting", leaseToken, expires, timestamp, timestamp);
|
|
389
394
|
return { id, leaseToken, reused: false };
|
|
390
395
|
}
|
|
391
396
|
|
|
397
|
+
renewRunLease(runId: string, leaseToken: string, leaseMs = 15 * 60_000): boolean {
|
|
398
|
+
const timestamp = now();
|
|
399
|
+
const result = this.db.query(`UPDATE initialization_runs
|
|
400
|
+
SET lease_expires_at=?,updated_at=?
|
|
401
|
+
WHERE id=? AND lease_token=? AND status IN ('preparing','collecting','validating')`).run(
|
|
402
|
+
new Date(Date.now() + leaseMs).toISOString(), timestamp, runId, leaseToken,
|
|
403
|
+
);
|
|
404
|
+
return result.changes === 1;
|
|
405
|
+
}
|
|
406
|
+
|
|
392
407
|
private assertWritableRun(runId: string, leaseToken: string): { status: RunStatus; inputSnapshotId: string; workspaceId: string; configId: string } {
|
|
393
408
|
const row = this.db.query("SELECT status,input_snapshot_id AS inputSnapshotId,workspace_id AS workspaceId,config_id AS configId,lease_token AS leaseToken,lease_expires_at AS expires FROM initialization_runs WHERE id=?").get(runId) as ({ status: RunStatus; inputSnapshotId: string; workspaceId: string; configId: string; leaseToken: string; expires: string }) | null;
|
|
394
409
|
if (!row) throw new Error(`Unknown initialization run: ${runId}`);
|
|
@@ -193,9 +193,24 @@ function collect(files: SourceFile[], context: ProjectContext, config: ScanConfi
|
|
|
193
193
|
return facts;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
async function runProcess(argv: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
|
|
196
|
+
async function runProcess(argv: string[], cwd: string, label = path.basename(argv[1] ?? argv[0])): Promise<{ stdout: string; stderr: string }> {
|
|
197
197
|
const child = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
198
|
-
const
|
|
198
|
+
const readStream = async (stream: ReadableStream<Uint8Array>, emit?: (line: string) => void): Promise<string> => {
|
|
199
|
+
const reader = stream.getReader(); const decoder = new TextDecoder(); let text = ""; const chunks: string[] = [];
|
|
200
|
+
while (true) {
|
|
201
|
+
const chunk = await reader.read(); if (chunk.done) break;
|
|
202
|
+
const decoded = decoder.decode(chunk.value, { stream: true }); chunks.push(decoded); text += decoded;
|
|
203
|
+
const lines = text.split(/\r?\n/); text = lines.pop() ?? "";
|
|
204
|
+
for (const line of lines) if (line) emit?.(line);
|
|
205
|
+
}
|
|
206
|
+
const tail = decoder.decode(); chunks.push(tail); text += tail; if (text) emit?.(text);
|
|
207
|
+
return chunks.join("");
|
|
208
|
+
};
|
|
209
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
210
|
+
child.exited,
|
|
211
|
+
readStream(child.stdout),
|
|
212
|
+
readStream(child.stderr, line => console.error(`[${label}] ${line}`)),
|
|
213
|
+
]);
|
|
199
214
|
if (exitCode !== 0) throw new Error(`${path.basename(argv[1] ?? argv[0])} failed (${exitCode}): ${(stderr || stdout).trim()}`);
|
|
200
215
|
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
201
216
|
}
|
|
@@ -204,9 +219,9 @@ async function buildCodeIndex(context: ProjectContext, config: ScanConfigInput):
|
|
|
204
219
|
const script = path.resolve(import.meta.dir, "../plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py");
|
|
205
220
|
const output = `.planning/.code-index-pending-${randomUUID()}`;
|
|
206
221
|
const codebases = config.repositories.flatMap(repository => ["--codebase", `${repository.name}:${path.resolve(context.root, repository.relativePath)}:${repository.role}`]);
|
|
207
|
-
await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root);
|
|
208
|
-
await runProcess(["python3", script, "validate", "--index", output], context.root);
|
|
209
|
-
await runProcess(["python3", script, "audit", "--root", context.root, "--index", output, "--skill-root", path.resolve(import.meta.dir, "../plugins/devflow-intel/skills")], context.root);
|
|
222
|
+
await runProcess(["python3", script, "scan", "--root", context.root, "--output", output, ...codebases], context.root, "code-index scan");
|
|
223
|
+
await runProcess(["python3", script, "validate", "--index", output], context.root, "code-index validate");
|
|
224
|
+
await runProcess(["python3", script, "audit", "--root", context.root, "--index", output, "--skill-root", path.resolve(import.meta.dir, "../plugins/devflow-intel/skills")], context.root, "code-index audit");
|
|
210
225
|
const snapshotPath = path.join(context.root, output, "SNAPSHOT.json");
|
|
211
226
|
const bytes = await readFile(snapshotPath); const snapshot = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
|
212
227
|
if (!snapshot.generation || !snapshot.immutablePath) throw new Error("Code index did not publish an immutable generation");
|
|
@@ -239,6 +254,7 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
239
254
|
const config: ScanConfigInput = input.config ?? { mode: "full", repositories: [{ name: context.manifest.name, relativePath: ".", role: "other" }], requiredKinds: [...FACT_KINDS], adapterVersions: { static: "1" } };
|
|
240
255
|
const store = await InitializationStore.open(context); const diagnostics: string[] = [];
|
|
241
256
|
let activeRun: { id: string; leaseToken: string } | undefined;
|
|
257
|
+
let leaseHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
242
258
|
try {
|
|
243
259
|
const workspace = await store.registerWorkspace(); const storedConfig = store.putScanConfig(config);
|
|
244
260
|
const initialScan = await scan(context, config); const files = initialScan.files;
|
|
@@ -255,6 +271,13 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
255
271
|
workspaceId: context.workspaceId,
|
|
256
272
|
planningRoot: context.planningRoot,
|
|
257
273
|
};
|
|
274
|
+
leaseHeartbeat = setInterval(() => {
|
|
275
|
+
try {
|
|
276
|
+
if (!store.renewRunLease(run.id, run.leaseToken)) diagnostics.push("Initialization lease heartbeat could not be renewed");
|
|
277
|
+
} catch (error) {
|
|
278
|
+
diagnostics.push(`Initialization lease heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
279
|
+
}
|
|
280
|
+
}, 60_000);
|
|
258
281
|
const grouped = collect(files, context, config); const required = new Set(config.requiredKinds ?? FACT_KINDS);
|
|
259
282
|
for (const repository of config.repositories) for (const kind of FACT_KINDS) {
|
|
260
283
|
const repositoryFacts = grouped.get(kind)!.filter(fact => fact.repository === repository.name);
|
|
@@ -331,5 +354,8 @@ export async function runProjectInitialization(context: ProjectContext, input: {
|
|
|
331
354
|
diagnostics.push(message);
|
|
332
355
|
if (activeRun) store.failRun(activeRun.id, activeRun.leaseToken, message);
|
|
333
356
|
throw error;
|
|
334
|
-
} finally {
|
|
357
|
+
} finally {
|
|
358
|
+
if (leaseHeartbeat) clearInterval(leaseHeartbeat);
|
|
359
|
+
store.close();
|
|
360
|
+
}
|
|
335
361
|
}
|