@wundam/orchex 1.0.0-rc.1 → 1.0.0-rc.2
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/README.md +26 -155
- package/dist/artifacts.d.ts +86 -5
- package/dist/artifacts.js +433 -30
- package/dist/config.d.ts +6 -2
- package/dist/config.js +11 -2
- package/dist/context-builder.d.ts +2 -1
- package/dist/context-builder.js +30 -2
- package/dist/cost.js +1 -1
- package/dist/index.js +197 -3
- package/dist/intelligence/cost-tracker.js +1 -1
- package/dist/intelligence/diagnostics.d.ts +8 -0
- package/dist/intelligence/diagnostics.js +28 -0
- package/dist/logging.js +1 -1
- package/dist/orchestrator.js +54 -29
- package/dist/tiers.d.ts +8 -0
- package/dist/tiers.js +7 -1
- package/dist/tools.d.ts +33 -6
- package/dist/tools.js +10 -2
- package/package.json +4 -19
package/dist/artifacts.js
CHANGED
|
@@ -4,7 +4,8 @@ import { StreamArtifactSchema } from './types.js';
|
|
|
4
4
|
import { getArtifactPath, loadManifest } from './manifest.js';
|
|
5
5
|
import { checkOwnership as checkOwnershipEnhanced, } from './ownership.js';
|
|
6
6
|
import { createLogger } from './logging.js';
|
|
7
|
-
import { runCommands } from './commands.js';
|
|
7
|
+
import { runCommand, runCommands } from './commands.js';
|
|
8
|
+
import { jsonrepair } from 'jsonrepair';
|
|
8
9
|
const log = createLogger('artifacts');
|
|
9
10
|
// ============================================================================
|
|
10
11
|
// Read & Validate
|
|
@@ -296,10 +297,11 @@ function repairInvalidEscapes(json) {
|
|
|
296
297
|
}
|
|
297
298
|
/**
|
|
298
299
|
* Parse JSON string, normalize, and validate against schema.
|
|
299
|
-
* Returns null on any failure.
|
|
300
|
+
* Returns null artifact on any failure.
|
|
300
301
|
*/
|
|
301
302
|
function parseAndValidate(jsonStr, wasRepaired) {
|
|
302
303
|
let parsed;
|
|
304
|
+
let usedJsonRepair = false;
|
|
303
305
|
// Attempt 1: Direct parse
|
|
304
306
|
try {
|
|
305
307
|
parsed = JSON.parse(jsonStr);
|
|
@@ -314,19 +316,29 @@ function parseAndValidate(jsonStr, wasRepaired) {
|
|
|
314
316
|
catch { /* still invalid */ }
|
|
315
317
|
}
|
|
316
318
|
}
|
|
319
|
+
// Attempt 3: jsonrepair — handles trailing commas, single quotes, unquoted keys, comments
|
|
320
|
+
if (!parsed) {
|
|
321
|
+
try {
|
|
322
|
+
const repaired = jsonrepair(jsonStr);
|
|
323
|
+
parsed = JSON.parse(repaired);
|
|
324
|
+
usedJsonRepair = true;
|
|
325
|
+
log.info('artifact_json_repaired: jsonrepair recovered malformed JSON');
|
|
326
|
+
}
|
|
327
|
+
catch { /* jsonrepair could not fix it either */ }
|
|
328
|
+
}
|
|
317
329
|
if (!parsed) {
|
|
318
330
|
if (!wasRepaired) {
|
|
319
331
|
log.warn('Artifact block contains invalid JSON');
|
|
320
332
|
}
|
|
321
|
-
return null;
|
|
333
|
+
return { artifact: null, usedJsonRepair: false };
|
|
322
334
|
}
|
|
323
335
|
normalizeArtifactInput(parsed);
|
|
324
336
|
const result = StreamArtifactSchema.safeParse(parsed);
|
|
325
337
|
if (!result.success) {
|
|
326
338
|
log.warn({ issues: result.error.issues, rawKeys: Object.keys(parsed) }, 'Artifact validation failed after normalization');
|
|
327
|
-
return null;
|
|
339
|
+
return { artifact: null, usedJsonRepair: false };
|
|
328
340
|
}
|
|
329
|
-
return result.data;
|
|
341
|
+
return { artifact: result.data, usedJsonRepair };
|
|
330
342
|
}
|
|
331
343
|
/**
|
|
332
344
|
* Extract artifact JSON from an agent's response text with full diagnostics.
|
|
@@ -337,9 +349,9 @@ export function extractArtifactWithDiagnostics(agentOutput) {
|
|
|
337
349
|
// Strategy 1: Exact match (current behavior, fast path)
|
|
338
350
|
const exactMatch = agentOutput.match(/```orchex-artifact\s*\n([\s\S]*?)\n?```/);
|
|
339
351
|
if (exactMatch) {
|
|
340
|
-
const artifact = parseAndValidate(exactMatch[1].trim(), false);
|
|
352
|
+
const { artifact, usedJsonRepair } = parseAndValidate(exactMatch[1].trim(), false);
|
|
341
353
|
if (artifact) {
|
|
342
|
-
return { artifact, diagnostic: { strategy: 'exact', jsonRepaired:
|
|
354
|
+
return { artifact, diagnostic: { strategy: 'exact', jsonRepaired: usedJsonRepair, rawLength } };
|
|
343
355
|
}
|
|
344
356
|
// Matched the fence but JSON was invalid — report as exact strategy with error
|
|
345
357
|
return {
|
|
@@ -368,17 +380,17 @@ export function extractArtifactWithDiagnostics(agentOutput) {
|
|
|
368
380
|
// Remove trailing ``` if present (could be at end of string without newline)
|
|
369
381
|
jsonContent = jsonContent.replace(/```\s*$/, '').trim();
|
|
370
382
|
// Try parsing as-is first
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
373
|
-
return { artifact:
|
|
383
|
+
const directResult = parseAndValidate(jsonContent, false);
|
|
384
|
+
if (directResult.artifact) {
|
|
385
|
+
return { artifact: directResult.artifact, diagnostic: { strategy: 'fence_recovery', jsonRepaired: directResult.usedJsonRepair, rawLength } };
|
|
374
386
|
}
|
|
375
387
|
// Try repairing truncated JSON
|
|
376
388
|
const repaired = repairTruncatedJson(jsonContent);
|
|
377
389
|
if (repaired) {
|
|
378
|
-
const
|
|
379
|
-
if (
|
|
390
|
+
const repairedResult = parseAndValidate(repaired, true);
|
|
391
|
+
if (repairedResult.artifact) {
|
|
380
392
|
log.info('artifact_json_repaired: truncated JSON was recovered');
|
|
381
|
-
return { artifact:
|
|
393
|
+
return { artifact: repairedResult.artifact, diagnostic: { strategy: 'fence_recovery', jsonRepaired: true, rawLength } };
|
|
382
394
|
}
|
|
383
395
|
}
|
|
384
396
|
return {
|
|
@@ -432,6 +444,167 @@ export function checkOwnership(operations, owns, options) {
|
|
|
432
444
|
export function checkOwnershipDetailed(operations, owns, options) {
|
|
433
445
|
return checkOwnershipEnhanced(operations, owns, options);
|
|
434
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Check if TypeScript syntax validation is available in this project.
|
|
449
|
+
* Requires tsconfig.json to exist (indicates a TS project).
|
|
450
|
+
*/
|
|
451
|
+
async function isTscAvailable(projectDir) {
|
|
452
|
+
try {
|
|
453
|
+
await fs.access(path.join(projectDir, 'tsconfig.json'));
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Filter tsc output to only include error lines referencing specific files.
|
|
462
|
+
* tsc error format: `path/to/file.ts(line,col): error TSxxxx: message`
|
|
463
|
+
* Strips node_modules errors and errors from files not in the target set.
|
|
464
|
+
*/
|
|
465
|
+
function filterTscErrors(output, targetFiles) {
|
|
466
|
+
return output
|
|
467
|
+
.split('\n')
|
|
468
|
+
.filter(line => {
|
|
469
|
+
// Skip blank lines and node_modules errors
|
|
470
|
+
if (!line.trim() || line.startsWith('node_modules/'))
|
|
471
|
+
return false;
|
|
472
|
+
// Match tsc error format: `relative/path.ts(line,col):`
|
|
473
|
+
const match = line.match(/^(.+?)\(\d+,\d+\):/);
|
|
474
|
+
if (!match)
|
|
475
|
+
return false;
|
|
476
|
+
return targetFiles.has(match[1]);
|
|
477
|
+
})
|
|
478
|
+
.join('\n');
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Resolve which TS files fall inside tsconfig's `include` paths.
|
|
482
|
+
* Files outside include (e.g. tests/) need per-file fallback validation.
|
|
483
|
+
*/
|
|
484
|
+
async function resolveTsconfigInclude(projectDir) {
|
|
485
|
+
try {
|
|
486
|
+
const raw = await fs.readFile(path.join(projectDir, 'tsconfig.json'), 'utf-8');
|
|
487
|
+
const tsconfig = JSON.parse(raw);
|
|
488
|
+
return tsconfig.include ?? [];
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Validate syntax of files modified by artifact operations.
|
|
496
|
+
*
|
|
497
|
+
* Strategy:
|
|
498
|
+
* - JSON: in-process JSON.parse (fast, no child process)
|
|
499
|
+
* - JS/MJS/CJS: node --check (per-file syntax check)
|
|
500
|
+
* - TS/TSX inside tsconfig include: project-wide `tsc --noEmit --project tsconfig.json`,
|
|
501
|
+
* filtered to only report errors from stream-owned files. This respects skipLibCheck,
|
|
502
|
+
* global type augmentations (declare global), and include/exclude paths.
|
|
503
|
+
* - TS/TSX outside tsconfig include (e.g. tests/): per-file `tsc --noEmit
|
|
504
|
+
* --isolatedModules --skipLibCheck`, filtered to exclude node_modules errors.
|
|
505
|
+
*
|
|
506
|
+
* Returns valid:true if all files pass or have unsupported extensions.
|
|
507
|
+
*/
|
|
508
|
+
export async function validateSyntax(projectDir, operations) {
|
|
509
|
+
const errors = [];
|
|
510
|
+
// Collect unique files to validate (skip deletes)
|
|
511
|
+
const filesToCheck = [...new Set(operations
|
|
512
|
+
.filter(op => op.type !== 'delete')
|
|
513
|
+
.map(op => op.path))];
|
|
514
|
+
// Partition TS files vs non-TS files
|
|
515
|
+
const tsFiles = [];
|
|
516
|
+
const otherFiles = [];
|
|
517
|
+
for (const f of filesToCheck) {
|
|
518
|
+
const ext = path.extname(f).toLowerCase();
|
|
519
|
+
if (ext === '.ts' || ext === '.tsx') {
|
|
520
|
+
tsFiles.push(f);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
otherFiles.push(f);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// Only attempt TS validation if the project has a tsconfig.json
|
|
527
|
+
const hasTsc = tsFiles.length > 0 ? await isTscAvailable(projectDir) : false;
|
|
528
|
+
// --- TypeScript validation (project-aware) ---
|
|
529
|
+
if (hasTsc && tsFiles.length > 0) {
|
|
530
|
+
const includePaths = await resolveTsconfigInclude(projectDir);
|
|
531
|
+
// Split TS files into "inside tsconfig include" vs "outside" (e.g. tests/)
|
|
532
|
+
const insideProject = [];
|
|
533
|
+
const outsideProject = [];
|
|
534
|
+
for (const f of tsFiles) {
|
|
535
|
+
const isIncluded = includePaths.length === 0 || includePaths.some(inc => f.startsWith(inc.replace(/\/?\*\*.*$/, '')));
|
|
536
|
+
if (isIncluded) {
|
|
537
|
+
insideProject.push(f);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
outsideProject.push(f);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Project-wide tsc for files inside tsconfig include
|
|
544
|
+
if (insideProject.length > 0) {
|
|
545
|
+
const targetSet = new Set(insideProject);
|
|
546
|
+
const result = await runCommand('npx tsc --noEmit --project tsconfig.json', projectDir, { timeoutMs: 15000 });
|
|
547
|
+
if (!result.success) {
|
|
548
|
+
if (result.exitCode === null) {
|
|
549
|
+
log.debug('tsc_project_unavailable');
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
const rawOutput = (result.stderr || result.stdout || '').trim();
|
|
553
|
+
const filtered = filterTscErrors(rawOutput, targetSet);
|
|
554
|
+
if (filtered.trim()) {
|
|
555
|
+
errors.push(`${insideProject.join(', ')}: syntax validation failed — ${filtered}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Per-file fallback for files outside tsconfig include (tests, etc.)
|
|
561
|
+
for (const filePath of outsideProject) {
|
|
562
|
+
const fullPath = path.join(projectDir, filePath);
|
|
563
|
+
const result = await runCommand(`npx tsc --noEmit --isolatedModules --skipLibCheck "${fullPath}"`, projectDir, { timeoutMs: 5000 });
|
|
564
|
+
if (!result.success) {
|
|
565
|
+
if (result.exitCode === null) {
|
|
566
|
+
log.debug({ filePath }, 'syntax_validator_unavailable');
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const rawOutput = (result.stderr || result.stdout || '').trim();
|
|
570
|
+
const targetSet = new Set([filePath, fullPath]);
|
|
571
|
+
const filtered = filterTscErrors(rawOutput, targetSet);
|
|
572
|
+
if (filtered.trim()) {
|
|
573
|
+
errors.push(`${filePath}: syntax validation failed — ${filtered}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// --- Non-TS file validation (JSON, JS) ---
|
|
579
|
+
for (const filePath of otherFiles) {
|
|
580
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
581
|
+
const fullPath = path.join(projectDir, filePath);
|
|
582
|
+
// JSON: in-process parse (no child process needed)
|
|
583
|
+
if (ext === '.json') {
|
|
584
|
+
try {
|
|
585
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
586
|
+
JSON.parse(content);
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
errors.push(`${filePath}: JSON syntax error — ${err instanceof Error ? err.message : err}`);
|
|
590
|
+
}
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// JS: node --check
|
|
594
|
+
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
595
|
+
const result = await runCommand(`node --check "${fullPath}"`, projectDir, { timeoutMs: 5000 });
|
|
596
|
+
if (!result.success) {
|
|
597
|
+
if (result.exitCode === null) {
|
|
598
|
+
log.debug({ filePath, error: result.error }, 'syntax_validator_unavailable');
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const detail = (result.stderr || result.stdout || '').trim();
|
|
602
|
+
errors.push(`${filePath}: syntax validation failed — ${detail}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { valid: errors.length === 0, errors };
|
|
607
|
+
}
|
|
435
608
|
/**
|
|
436
609
|
* Apply a stream's artifact to the project codebase.
|
|
437
610
|
* Creates a backup before applying. Rolls back on error.
|
|
@@ -492,6 +665,18 @@ export async function applyArtifact(projectDir, streamId, options = {}) {
|
|
|
492
665
|
await applyOperation(projectDir, op);
|
|
493
666
|
appliedCount++;
|
|
494
667
|
}
|
|
668
|
+
// Post-apply syntax validation — revert if broken syntax applied
|
|
669
|
+
const syntaxResult = await validateSyntax(projectDir, artifact.operations);
|
|
670
|
+
if (!syntaxResult.valid) {
|
|
671
|
+
log.warn({ streamId, errors: syntaxResult.errors }, 'syntax_validation_failed');
|
|
672
|
+
await revertStreamBackup(projectDir, backup);
|
|
673
|
+
return {
|
|
674
|
+
success: false,
|
|
675
|
+
streamId,
|
|
676
|
+
appliedOps: appliedCount,
|
|
677
|
+
error: `Syntax validation failed, rolled back: ${syntaxResult.errors.join('; ')}`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
495
680
|
return { success: true, streamId, appliedOps: appliedCount };
|
|
496
681
|
}
|
|
497
682
|
catch (err) {
|
|
@@ -708,6 +893,138 @@ export async function createStreamBackup(projectDir, streamId, operations) {
|
|
|
708
893
|
}
|
|
709
894
|
return { streamId, entries, createdFiles };
|
|
710
895
|
}
|
|
896
|
+
const BACKUPS_DIR = 'backups';
|
|
897
|
+
function backupsPath(projectDir) {
|
|
898
|
+
return path.join(projectDir, '.orchex', 'active', BACKUPS_DIR);
|
|
899
|
+
}
|
|
900
|
+
function backupFilePath(projectDir, streamId) {
|
|
901
|
+
return path.join(backupsPath(projectDir), `${streamId}.json`);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Persist a stream backup to disk as JSON.
|
|
905
|
+
* Stored in .orchex/active/backups/<streamId>.json
|
|
906
|
+
*/
|
|
907
|
+
export async function writeBackup(projectDir, backup) {
|
|
908
|
+
const dir = backupsPath(projectDir);
|
|
909
|
+
await fs.mkdir(dir, { recursive: true });
|
|
910
|
+
await fs.writeFile(backupFilePath(projectDir, backup.streamId), JSON.stringify(backup), 'utf-8');
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Read a single backup from disk. Returns null if not found.
|
|
914
|
+
*/
|
|
915
|
+
export async function readBackup(projectDir, streamId) {
|
|
916
|
+
try {
|
|
917
|
+
const raw = await fs.readFile(backupFilePath(projectDir, streamId), 'utf-8');
|
|
918
|
+
return JSON.parse(raw);
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Read all persisted backups from disk.
|
|
926
|
+
*/
|
|
927
|
+
export async function readAllBackups(projectDir) {
|
|
928
|
+
const dir = backupsPath(projectDir);
|
|
929
|
+
try {
|
|
930
|
+
const files = await fs.readdir(dir);
|
|
931
|
+
const backups = [];
|
|
932
|
+
for (const file of files) {
|
|
933
|
+
if (!file.endsWith('.json'))
|
|
934
|
+
continue;
|
|
935
|
+
try {
|
|
936
|
+
const raw = await fs.readFile(path.join(dir, file), 'utf-8');
|
|
937
|
+
backups.push(JSON.parse(raw));
|
|
938
|
+
}
|
|
939
|
+
catch {
|
|
940
|
+
// Skip corrupt files
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return backups;
|
|
944
|
+
}
|
|
945
|
+
catch {
|
|
946
|
+
return [];
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Delete a single backup file.
|
|
951
|
+
*/
|
|
952
|
+
export async function deleteBackup(projectDir, streamId) {
|
|
953
|
+
try {
|
|
954
|
+
await fs.unlink(backupFilePath(projectDir, streamId));
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
// Already gone — no-op
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function isolationStatePath(projectDir) {
|
|
961
|
+
return path.join(projectDir, '.orchex', 'active', '.isolation-state');
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Write isolation state to disk before each dangerous phase.
|
|
965
|
+
* If the process crashes, recovery can read this to know which
|
|
966
|
+
* stream was being tested and restore from its disk backup.
|
|
967
|
+
*/
|
|
968
|
+
export async function writeIsolationState(projectDir, streamId, phase) {
|
|
969
|
+
const state = { streamId, phase, timestamp: new Date().toISOString() };
|
|
970
|
+
const dir = path.dirname(isolationStatePath(projectDir));
|
|
971
|
+
await fs.mkdir(dir, { recursive: true });
|
|
972
|
+
await fs.writeFile(isolationStatePath(projectDir), JSON.stringify(state), 'utf-8');
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Read isolation state. Returns null if no state file exists.
|
|
976
|
+
*/
|
|
977
|
+
export async function readIsolationState(projectDir) {
|
|
978
|
+
try {
|
|
979
|
+
const raw = await fs.readFile(isolationStatePath(projectDir), 'utf-8');
|
|
980
|
+
return JSON.parse(raw);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Clear isolation state after successful completion or recovery.
|
|
988
|
+
*/
|
|
989
|
+
export async function clearIsolationState(projectDir) {
|
|
990
|
+
try {
|
|
991
|
+
await fs.unlink(isolationStatePath(projectDir));
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
// Already gone
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Recover from a crash that happened during verify isolation.
|
|
999
|
+
* Reads .isolation-state to determine which stream was being tested,
|
|
1000
|
+
* then restores it from its disk backup.
|
|
1001
|
+
*
|
|
1002
|
+
* Returns true if recovery was performed, false if no recovery needed.
|
|
1003
|
+
*/
|
|
1004
|
+
export async function recoverFromIsolationCrash(projectDir) {
|
|
1005
|
+
const state = await readIsolationState(projectDir);
|
|
1006
|
+
if (!state)
|
|
1007
|
+
return false;
|
|
1008
|
+
log.warn({ streamId: state.streamId, phase: state.phase }, 'Recovering from isolation crash');
|
|
1009
|
+
const backup = await readBackup(projectDir, state.streamId);
|
|
1010
|
+
if (!backup) {
|
|
1011
|
+
log.warn({ streamId: state.streamId }, 'No disk backup found for crashed isolation stream');
|
|
1012
|
+
await clearIsolationState(projectDir);
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
// If we crashed during 'reverting' or 'testing', the files may be in a
|
|
1016
|
+
// reverted state. We need to restore the stream's changes.
|
|
1017
|
+
// If we crashed during 'restoring', the restore may be partial — re-apply.
|
|
1018
|
+
try {
|
|
1019
|
+
await restoreStreamBackup(projectDir, backup);
|
|
1020
|
+
log.info({ streamId: state.streamId }, 'Isolation crash recovery: stream restored from disk backup');
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
log.error({ streamId: state.streamId, error: err.message }, 'Isolation crash recovery failed');
|
|
1024
|
+
}
|
|
1025
|
+
await clearIsolationState(projectDir);
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
711
1028
|
/**
|
|
712
1029
|
* Revert a stream's changes using its backup.
|
|
713
1030
|
* Restores modified files and removes created files.
|
|
@@ -771,27 +1088,80 @@ export async function restoreStreamBackup(projectDir, backup) {
|
|
|
771
1088
|
// ============================================================================
|
|
772
1089
|
// Verify Isolation
|
|
773
1090
|
// ============================================================================
|
|
1091
|
+
/**
|
|
1092
|
+
* Execute a function with only the specified stream's changes on disk.
|
|
1093
|
+
* Temporarily reverts all other streams' backups, runs fn, then restores them.
|
|
1094
|
+
* Uses crash-recovery state machine (.isolation-state) for safety.
|
|
1095
|
+
*
|
|
1096
|
+
* If otherBackups is empty, fn is called directly without isolation.
|
|
1097
|
+
*/
|
|
1098
|
+
export async function withStreamIsolation(projectDir, streamId, otherBackups, fn) {
|
|
1099
|
+
if (otherBackups.length === 0) {
|
|
1100
|
+
return fn();
|
|
1101
|
+
}
|
|
1102
|
+
// Save current state of files modified by other streams (for restore)
|
|
1103
|
+
const currentStates = [];
|
|
1104
|
+
try {
|
|
1105
|
+
// Phase 1: Revert all other streams' changes
|
|
1106
|
+
await writeIsolationState(projectDir, streamId, 'reverting');
|
|
1107
|
+
for (const backup of otherBackups) {
|
|
1108
|
+
const currentState = await createStreamBackup(projectDir, `${backup.streamId}-current`, backup.entries.map(e => ({ type: 'edit', path: e.path, edits: [] })));
|
|
1109
|
+
for (const createdFile of backup.createdFiles) {
|
|
1110
|
+
try {
|
|
1111
|
+
const content = await fs.readFile(path.join(projectDir, createdFile), 'utf-8');
|
|
1112
|
+
currentState.entries.push({ path: createdFile, content });
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// File doesn't exist
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
currentStates.push(currentState);
|
|
1119
|
+
await revertStreamBackup(projectDir, backup);
|
|
1120
|
+
}
|
|
1121
|
+
// Phase 2: Run fn with only this stream's changes on disk
|
|
1122
|
+
await writeIsolationState(projectDir, streamId, 'testing');
|
|
1123
|
+
return await fn();
|
|
1124
|
+
}
|
|
1125
|
+
finally {
|
|
1126
|
+
// Phase 3: ALWAYS restore all other streams' changes
|
|
1127
|
+
await writeIsolationState(projectDir, streamId, 'restoring');
|
|
1128
|
+
for (const state of currentStates) {
|
|
1129
|
+
await restoreStreamBackup(projectDir, state);
|
|
1130
|
+
}
|
|
1131
|
+
await clearIsolationState(projectDir);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
774
1134
|
/**
|
|
775
1135
|
* Isolate which stream(s) caused a verify failure by temporarily
|
|
776
1136
|
* reverting each stream's changes and re-running its verify commands.
|
|
777
1137
|
*
|
|
778
|
-
* Returns
|
|
779
|
-
* - 'guilty'
|
|
780
|
-
* -
|
|
781
|
-
* - 'unknown': couldn't determine (interaction between streams)
|
|
1138
|
+
* Returns an IsolationResult with:
|
|
1139
|
+
* - verdicts: streamId → 'guilty' | 'innocent' | 'unknown'
|
|
1140
|
+
* - fileOverlaps: files modified by multiple streams (diagnostic hint)
|
|
782
1141
|
*/
|
|
783
|
-
export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap) {
|
|
1142
|
+
export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap, options) {
|
|
784
1143
|
const verdicts = new Map();
|
|
785
1144
|
// Single stream: it's trivially the cause
|
|
786
1145
|
if (backups.length === 1) {
|
|
787
1146
|
verdicts.set(backups[0].streamId, 'guilty');
|
|
788
|
-
return verdicts;
|
|
1147
|
+
return { verdicts, fileOverlaps: [] };
|
|
789
1148
|
}
|
|
1149
|
+
const deadline = Date.now() + (options?.timeoutMs ?? 120_000);
|
|
790
1150
|
// Collect all unique failed commands for cross-stream testing
|
|
791
1151
|
const allFailedCmds = [...new Set([...failedVerifyMap.values()].flat())];
|
|
792
1152
|
// For each stream, temporarily revert its changes and re-run verify
|
|
793
1153
|
let foundGuilty = false;
|
|
794
1154
|
for (const backup of backups) {
|
|
1155
|
+
// Check if we've exceeded the timeout
|
|
1156
|
+
if (Date.now() >= deadline) {
|
|
1157
|
+
// Mark all remaining untested streams as unknown
|
|
1158
|
+
for (const b of backups) {
|
|
1159
|
+
if (!verdicts.has(b.streamId)) {
|
|
1160
|
+
verdicts.set(b.streamId, 'unknown');
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
795
1165
|
// Save current state of files this stream modified
|
|
796
1166
|
const currentState = await createStreamBackup(projectDir, `${backup.streamId}-current`, backup.entries.map(e => ({ type: 'edit', path: e.path, edits: [] })));
|
|
797
1167
|
// Also track created files that currently exist
|
|
@@ -804,16 +1174,27 @@ export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap)
|
|
|
804
1174
|
// File doesn't exist, nothing to save
|
|
805
1175
|
}
|
|
806
1176
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1177
|
+
let passed = false;
|
|
1178
|
+
try {
|
|
1179
|
+
// Revert this stream's changes
|
|
1180
|
+
await writeIsolationState(projectDir, backup.streamId, 'reverting');
|
|
1181
|
+
await revertStreamBackup(projectDir, backup);
|
|
1182
|
+
// Use this stream's own failed verify commands if it has them,
|
|
1183
|
+
// otherwise use all failed commands (cross-stream check)
|
|
1184
|
+
const ownFailedCmds = failedVerifyMap.get(backup.streamId);
|
|
1185
|
+
const cmdsToTest = ownFailedCmds ?? allFailedCmds;
|
|
1186
|
+
await writeIsolationState(projectDir, backup.streamId, 'testing');
|
|
1187
|
+
const remainingMs = deadline - Date.now();
|
|
1188
|
+
const perCmdTimeout = Math.max(remainingMs, 1000);
|
|
1189
|
+
const results = await runCommands(cmdsToTest, projectDir, { timeoutMs: perCmdTimeout });
|
|
1190
|
+
passed = results.every(r => r.success);
|
|
1191
|
+
}
|
|
1192
|
+
finally {
|
|
1193
|
+
// ALWAYS restore — even on timeout or error
|
|
1194
|
+
await writeIsolationState(projectDir, backup.streamId, 'restoring');
|
|
1195
|
+
await restoreStreamBackup(projectDir, currentState);
|
|
1196
|
+
}
|
|
1197
|
+
await clearIsolationState(projectDir);
|
|
817
1198
|
if (passed) {
|
|
818
1199
|
verdicts.set(backup.streamId, 'guilty');
|
|
819
1200
|
foundGuilty = true;
|
|
@@ -828,5 +1209,27 @@ export async function isolateVerifyFailure(projectDir, backups, failedVerifyMap)
|
|
|
828
1209
|
verdicts.set(backup.streamId, 'unknown');
|
|
829
1210
|
}
|
|
830
1211
|
}
|
|
831
|
-
|
|
1212
|
+
// Final cleanup — ensure state file is removed
|
|
1213
|
+
await clearIsolationState(projectDir);
|
|
1214
|
+
// Compute file overlaps for diagnostic hints
|
|
1215
|
+
const fileToStreams = new Map();
|
|
1216
|
+
for (const backup of backups) {
|
|
1217
|
+
for (const entry of backup.entries) {
|
|
1218
|
+
const streams = fileToStreams.get(entry.path) ?? [];
|
|
1219
|
+
streams.push(backup.streamId);
|
|
1220
|
+
fileToStreams.set(entry.path, streams);
|
|
1221
|
+
}
|
|
1222
|
+
for (const created of backup.createdFiles) {
|
|
1223
|
+
const streams = fileToStreams.get(created) ?? [];
|
|
1224
|
+
streams.push(backup.streamId);
|
|
1225
|
+
fileToStreams.set(created, streams);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
const fileOverlaps = [];
|
|
1229
|
+
for (const [filePath, streams] of fileToStreams) {
|
|
1230
|
+
if (streams.length > 1) {
|
|
1231
|
+
fileOverlaps.push({ path: filePath, streams });
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return { verdicts, fileOverlaps };
|
|
832
1235
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
export declare const PRODUCTION_URL = "https://
|
|
2
|
+
export declare const PRODUCTION_URL = "https://orchex.dev";
|
|
3
3
|
export declare const LLMProviderSchema: z.ZodEnum<["anthropic", "openai", "gemini", "ollama", "deepseek"]>;
|
|
4
4
|
export type LLMProvider = z.infer<typeof LLMProviderSchema>;
|
|
5
5
|
/**
|
|
@@ -42,7 +42,7 @@ export declare const TelemetryConfigSchema: z.ZodObject<{
|
|
|
42
42
|
}>;
|
|
43
43
|
export declare const ConfigSchema: z.ZodObject<{
|
|
44
44
|
mode: z.ZodDefault<z.ZodEnum<["local", "cloud"]>>;
|
|
45
|
-
apiUrl: z.ZodDefault<z.ZodString
|
|
45
|
+
apiUrl: z.ZodDefault<z.ZodEffects<z.ZodString, string, string>>;
|
|
46
46
|
apiKey: z.ZodOptional<z.ZodString>;
|
|
47
47
|
/** User's subscription tier (synced from cloud on login) */
|
|
48
48
|
tier: z.ZodDefault<z.ZodEnum<["free", "pro", "team", "enterprise"]>>;
|
|
@@ -98,3 +98,7 @@ export declare function loadConfig(): Promise<Config>;
|
|
|
98
98
|
* Save partial config. Merges with existing config.
|
|
99
99
|
*/
|
|
100
100
|
export declare function saveConfig(partial: Partial<Config>): Promise<Config>;
|
|
101
|
+
/**
|
|
102
|
+
* Returns config with apiKey masked for safe display (CLI output, logs).
|
|
103
|
+
*/
|
|
104
|
+
export declare function maskConfigForDisplay(config: Config): Record<string, unknown>;
|
package/dist/config.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as os from 'os';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { TierIdSchema } from './tiers.js';
|
|
6
6
|
// Well-known URLs
|
|
7
|
-
export const PRODUCTION_URL = 'https://
|
|
7
|
+
export const PRODUCTION_URL = 'https://orchex.dev';
|
|
8
8
|
// ============================================================================
|
|
9
9
|
// LLM Provider Configuration
|
|
10
10
|
// ============================================================================
|
|
@@ -117,7 +117,7 @@ export const TelemetryConfigSchema = z.object({
|
|
|
117
117
|
});
|
|
118
118
|
export const ConfigSchema = z.object({
|
|
119
119
|
mode: z.enum(['local', 'cloud']).default('local'),
|
|
120
|
-
apiUrl: z.string().url().default(PRODUCTION_URL),
|
|
120
|
+
apiUrl: z.string().url().refine((u) => u.startsWith('https://') || u.startsWith('http://localhost') || u.startsWith('http://127.0.0.1'), { message: 'apiUrl must use https:// (http://localhost and http://127.0.0.1 are allowed for development)' }).default(PRODUCTION_URL),
|
|
121
121
|
apiKey: z.string().optional(),
|
|
122
122
|
/** User's subscription tier (synced from cloud on login) */
|
|
123
123
|
tier: TierIdSchema.default('free'),
|
|
@@ -170,3 +170,12 @@ export async function saveConfig(partial) {
|
|
|
170
170
|
await fs.writeFile(configPath(), JSON.stringify(validated, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
171
171
|
return validated;
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Returns config with apiKey masked for safe display (CLI output, logs).
|
|
175
|
+
*/
|
|
176
|
+
export function maskConfigForDisplay(config) {
|
|
177
|
+
return {
|
|
178
|
+
...config,
|
|
179
|
+
apiKey: config.apiKey ? '...' + config.apiKey.slice(-6) : undefined,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -14,8 +14,9 @@ export declare function buildStreamContext(projectDir: string, owns: string[], r
|
|
|
14
14
|
export declare function buildDependencyContext(projectDir: string, deps: string[], manifest: Manifest): Promise<string>;
|
|
15
15
|
/**
|
|
16
16
|
* Build output instructions: artifact format, rules, constraints.
|
|
17
|
+
* Optionally includes provider-specific guidance for known LLM weaknesses.
|
|
17
18
|
*/
|
|
18
|
-
export declare function buildInstructions(streamId: string, owns: string[]): string;
|
|
19
|
+
export declare function buildInstructions(streamId: string, owns: string[], provider?: string): string;
|
|
19
20
|
/**
|
|
20
21
|
* Build the complete prompt for an agent, assembling all 4 context layers.
|
|
21
22
|
*/
|