@exaudeus/workrail 3.38.0 → 3.39.0

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.
@@ -702,4 +702,211 @@ function runHealthSummary(sessionId, raw) {
702
702
  }
703
703
  process.stdout.write('\n');
704
704
  }
705
+ const runCommand = program
706
+ .command('run')
707
+ .description('Run a coordinator script');
708
+ runCommand
709
+ .command('pr-review')
710
+ .description('Review open PRs autonomously: dispatch review sessions, route by findings, merge or escalate')
711
+ .requiredOption('-W, --workspace <path>', 'Absolute path to the git workspace')
712
+ .option('-r, --pr <number>', 'Review a specific PR number (repeatable)', (val, prev) => [...prev, parseInt(val, 10)], [])
713
+ .option('--dry-run', 'Print actions without dispatching sessions or merging')
714
+ .option('-p, --port <n>', 'Console HTTP server port (default: auto-discover from lock file, then 3456)', parseInt)
715
+ .action(async (options) => {
716
+ const { runPrReviewCoordinator, discoverConsolePort, } = await Promise.resolve().then(() => __importStar(require('./coordinators/pr-review.js')));
717
+ const { execFile: execFileRaw } = await Promise.resolve().then(() => __importStar(require('child_process')));
718
+ const execFilePromise = (0, util_1.promisify)(execFileRaw);
719
+ if (!path_1.default.isAbsolute(options.workspace)) {
720
+ process.stderr.write(`Error: --workspace must be an absolute path, got: ${options.workspace}\n`);
721
+ process.exit(1);
722
+ }
723
+ try {
724
+ const stat = await fs_1.default.promises.stat(options.workspace);
725
+ if (!stat.isDirectory()) {
726
+ process.stderr.write(`Error: --workspace must be an existing directory: ${options.workspace}\n`);
727
+ process.exit(1);
728
+ }
729
+ }
730
+ catch {
731
+ process.stderr.write(`Error: --workspace does not exist: ${options.workspace}\n`);
732
+ process.exit(1);
733
+ }
734
+ const port = await discoverConsolePort({
735
+ readFile: (p) => fs_1.default.promises.readFile(p, 'utf-8'),
736
+ homedir: os_1.default.homedir,
737
+ joinPath: path_1.default.join,
738
+ }, options.port);
739
+ const deps = {
740
+ spawnSession: async (workflowId, goal, workspace) => {
741
+ const url = `http://127.0.0.1:${port}/api/v2/auto/dispatch`;
742
+ try {
743
+ const response = await globalThis.fetch(url, {
744
+ method: 'POST',
745
+ headers: { 'Content-Type': 'application/json' },
746
+ body: JSON.stringify({ workflowId, goal, workspacePath: workspace }),
747
+ signal: AbortSignal.timeout(30000),
748
+ });
749
+ const body = await response.json();
750
+ if (!response.ok) {
751
+ const errMsg = typeof body['error'] === 'string' ? body['error'] : `HTTP ${response.status}`;
752
+ if (response.status === 503) {
753
+ return { kind: 'err', error: `WorkTrain daemon is not ready: ${errMsg}` };
754
+ }
755
+ return { kind: 'err', error: `Dispatch failed: ${errMsg}` };
756
+ }
757
+ if (body['success'] !== true || typeof body['data'] !== 'object') {
758
+ return { kind: 'err', error: 'Unexpected response from dispatch endpoint' };
759
+ }
760
+ const data = body['data'];
761
+ const handle = typeof data['sessionHandle'] === 'string' ? data['sessionHandle'] : '';
762
+ if (!handle) {
763
+ return { kind: 'err', error: 'Dispatch succeeded but no session handle returned' };
764
+ }
765
+ return { kind: 'ok', value: handle };
766
+ }
767
+ catch (e) {
768
+ const msg = e instanceof Error ? e.message : String(e);
769
+ const isConnRefused = msg.includes('ECONNREFUSED') || msg.includes('fetch failed');
770
+ if (isConnRefused) {
771
+ return { kind: 'err', error: `Could not connect to WorkTrain daemon on port ${port}. Ensure the daemon is running with: worktrain daemon` };
772
+ }
773
+ if (e instanceof Error && e.name === 'TimeoutError') {
774
+ return { kind: 'err', error: `Daemon request timed out after 30s` };
775
+ }
776
+ return { kind: 'err', error: `Dispatch request failed: ${msg}` };
777
+ }
778
+ },
779
+ awaitSessions: async (handles, timeoutMs) => {
780
+ const { executeWorktrainAwaitCommand } = await Promise.resolve().then(() => __importStar(require('./cli/commands/worktrain-await.js')));
781
+ let resolvedResult = null;
782
+ await executeWorktrainAwaitCommand({
783
+ fetch: (url) => globalThis.fetch(url),
784
+ readFile: (p) => fs_1.default.promises.readFile(p, 'utf-8'),
785
+ stdout: (line) => {
786
+ try {
787
+ resolvedResult = JSON.parse(line);
788
+ }
789
+ catch { }
790
+ },
791
+ stderr: (line) => process.stderr.write(line + '\n'),
792
+ homedir: os_1.default.homedir,
793
+ joinPath: path_1.default.join,
794
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
795
+ now: () => Date.now(),
796
+ }, {
797
+ sessions: [...handles].join(','),
798
+ mode: 'all',
799
+ timeout: `${Math.round(timeoutMs / 1000)}s`,
800
+ port,
801
+ });
802
+ if (resolvedResult === null) {
803
+ process.stderr.write(`[WARN coord:reason=await_failed] awaitSessions: could not get session results -- daemon may be unreachable or timed out. Returning all ${handles.length} session(s) as failed.\n`);
804
+ }
805
+ return resolvedResult ?? { results: [...handles].map((h) => ({
806
+ handle: h,
807
+ outcome: 'failed',
808
+ status: null,
809
+ durationMs: 0,
810
+ })), allSucceeded: false };
811
+ },
812
+ getAgentResult: async (sessionHandle) => {
813
+ try {
814
+ const sessionUrl = `http://127.0.0.1:${port}/api/v2/sessions/${encodeURIComponent(sessionHandle)}`;
815
+ const sessionRes = await globalThis.fetch(sessionUrl, { signal: AbortSignal.timeout(30000) });
816
+ if (!sessionRes.ok) {
817
+ process.stderr.write(`[WARN coord:reason=http_error status=${sessionRes.status} handle=${sessionHandle.slice(0, 16)}] getAgentResult: session fetch returned HTTP ${sessionRes.status}\n`);
818
+ return null;
819
+ }
820
+ const sessionBody = await sessionRes.json();
821
+ if (sessionBody['success'] !== true) {
822
+ process.stderr.write(`[WARN coord:reason=api_error handle=${sessionHandle.slice(0, 16)}] getAgentResult: session API returned success=false\n`);
823
+ return null;
824
+ }
825
+ const data = sessionBody['data'];
826
+ if (!data) {
827
+ process.stderr.write(`[WARN coord:reason=no_data handle=${sessionHandle.slice(0, 16)}] getAgentResult: session response missing data field\n`);
828
+ return null;
829
+ }
830
+ const runs = data['runs'];
831
+ if (!Array.isArray(runs) || runs.length === 0) {
832
+ process.stderr.write(`[WARN coord:reason=no_runs handle=${sessionHandle.slice(0, 16)}] getAgentResult: session has no runs\n`);
833
+ return null;
834
+ }
835
+ const firstRun = runs[0];
836
+ const tipNodeId = typeof firstRun['preferredTipNodeId'] === 'string'
837
+ ? firstRun['preferredTipNodeId']
838
+ : null;
839
+ if (!tipNodeId) {
840
+ process.stderr.write(`[WARN coord:reason=no_tip_node handle=${sessionHandle.slice(0, 16)}] getAgentResult: session run has no preferredTipNodeId\n`);
841
+ return null;
842
+ }
843
+ const nodeUrl = `http://127.0.0.1:${port}/api/v2/sessions/${encodeURIComponent(sessionHandle)}/nodes/${encodeURIComponent(tipNodeId)}`;
844
+ const nodeRes = await globalThis.fetch(nodeUrl, { signal: AbortSignal.timeout(30000) });
845
+ if (!nodeRes.ok) {
846
+ process.stderr.write(`[WARN coord:reason=node_http_error status=${nodeRes.status} handle=${sessionHandle.slice(0, 16)} node=${tipNodeId.slice(0, 16)}] getAgentResult: node fetch returned HTTP ${nodeRes.status}\n`);
847
+ return null;
848
+ }
849
+ const nodeBody = await nodeRes.json();
850
+ if (nodeBody['success'] !== true) {
851
+ process.stderr.write(`[WARN coord:reason=node_api_error handle=${sessionHandle.slice(0, 16)} node=${tipNodeId.slice(0, 16)}] getAgentResult: node API returned success=false\n`);
852
+ return null;
853
+ }
854
+ const nodeData = nodeBody['data'];
855
+ if (!nodeData) {
856
+ process.stderr.write(`[WARN coord:reason=no_node_data handle=${sessionHandle.slice(0, 16)} node=${tipNodeId.slice(0, 16)}] getAgentResult: node response missing data field\n`);
857
+ return null;
858
+ }
859
+ const recap = typeof nodeData['recapMarkdown'] === 'string' ? nodeData['recapMarkdown'] : null;
860
+ if (recap === null) {
861
+ process.stderr.write(`[WARN coord:reason=no_recap handle=${sessionHandle.slice(0, 16)} node=${tipNodeId.slice(0, 16)}] getAgentResult: node has no recapMarkdown\n`);
862
+ }
863
+ return recap;
864
+ }
865
+ catch (e) {
866
+ const msg = e instanceof Error ? e.message : String(e);
867
+ process.stderr.write(`[WARN coord:reason=exception handle=${sessionHandle.slice(0, 16)}] getAgentResult: ${msg}\n`);
868
+ return null;
869
+ }
870
+ },
871
+ listOpenPRs: async (workspace) => {
872
+ try {
873
+ const { stdout } = await execFilePromise('gh', ['pr', 'list', '--json', 'number,title,headRefName'], {
874
+ cwd: workspace,
875
+ timeout: 30000,
876
+ });
877
+ const parsed = JSON.parse(stdout);
878
+ return parsed.map((p) => ({ number: p.number, title: p.title, headRef: p.headRefName }));
879
+ }
880
+ catch {
881
+ return [];
882
+ }
883
+ },
884
+ mergePR: async (prNumber, workspace) => {
885
+ try {
886
+ await execFilePromise('gh', ['pr', 'merge', String(prNumber), '--squash', '--auto'], {
887
+ cwd: workspace,
888
+ timeout: 60000,
889
+ });
890
+ return { kind: 'ok', value: undefined };
891
+ }
892
+ catch (e) {
893
+ const msg = e instanceof Error ? e.message : String(e);
894
+ return { kind: 'err', error: msg };
895
+ }
896
+ },
897
+ writeFile: async (filePath, content) => {
898
+ await fs_1.default.promises.writeFile(filePath, content, 'utf-8');
899
+ },
900
+ stderr: (line) => process.stderr.write(line + '\n'),
901
+ now: () => Date.now(),
902
+ port,
903
+ };
904
+ const result = await runPrReviewCoordinator(deps, {
905
+ workspace: options.workspace,
906
+ prs: options.pr.length > 0 ? options.pr : undefined,
907
+ dryRun: options.dryRun ?? false,
908
+ port: options.port,
909
+ });
910
+ process.exit(result.hasErrors ? 1 : 0);
911
+ });
705
912
  program.parse();