@h-rig/server 0.0.6-alpha.13 → 0.0.6-alpha.15

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.
@@ -1074,6 +1074,18 @@ function readJsonlFileTail(path, options) {
1074
1074
  const completeLines = start > 0 ? lines.slice(1) : lines;
1075
1075
  return parseJsonlRecords(completeLines.filter(Boolean).slice(-limit));
1076
1076
  }
1077
+ async function readRunTimelinePage(projectRoot, runId, options = {}) {
1078
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
1079
+ const cursor = options.cursor == null ? 0 : Number.parseInt(options.cursor, 10);
1080
+ const entries = readJsonlFile(runTimelinePath(projectRoot, runId)).filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)));
1081
+ const startInclusive = Number.isFinite(cursor) ? Math.max(0, Math.min(cursor, entries.length)) : 0;
1082
+ const endExclusive = Math.min(entries.length, startInclusive + limit);
1083
+ return {
1084
+ entries: entries.slice(startInclusive, endExclusive).map((entry, offset) => ({ ...entry, cursor: startInclusive + offset + 1 })),
1085
+ nextCursor: String(endExclusive),
1086
+ hasMore: endExclusive < entries.length
1087
+ };
1088
+ }
1077
1089
  var INITIAL_RUN_LOG_TAIL_MAX_BYTES = 8 * 1024 * 1024;
1078
1090
  async function readRunLogsPage(projectRoot, runId, options = {}) {
1079
1091
  const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
@@ -2731,8 +2743,7 @@ function buildRunStartPatch(startedAt) {
2731
2743
  status: "preparing",
2732
2744
  startedAt,
2733
2745
  completedAt: null,
2734
- errorText: null,
2735
- serverPid: process.pid
2746
+ errorText: null
2736
2747
  };
2737
2748
  }
2738
2749
 
@@ -3214,6 +3225,11 @@ import {
3214
3225
  buildTaskRunLifecycleComment,
3215
3226
  updateConfiguredTaskSourceTask
3216
3227
  } from "@rig/runtime/control-plane/tasks/source-lifecycle";
3228
+ import {
3229
+ closeIssueAfterMergedPr,
3230
+ commitRunChanges,
3231
+ runPrAutomation
3232
+ } from "@rig/runtime/control-plane/native/pr-automation";
3217
3233
 
3218
3234
  // packages/server/src/scheduler.ts
3219
3235
  import { normalizeTaskLifecycleStatus } from "@rig/runtime/control-plane/state-sync/types";
@@ -3549,6 +3565,9 @@ function asRecord(value) {
3549
3565
  function asString(value) {
3550
3566
  return typeof value === "string" && value.trim().length > 0 ? value : undefined;
3551
3567
  }
3568
+ function asNumber(value) {
3569
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
3570
+ }
3552
3571
  async function defaultGraphQLFetch(query, variables, token) {
3553
3572
  const response = await fetch("https://api.github.com/graphql", {
3554
3573
  method: "POST",
@@ -3565,6 +3584,32 @@ async function defaultGraphQLFetch(query, variables, token) {
3565
3584
  }
3566
3585
  return json.data;
3567
3586
  }
3587
+ function projectNodesFrom(data) {
3588
+ const root = asRecord(data);
3589
+ const owner = asRecord(root?.organization) ?? asRecord(root?.user);
3590
+ const projects = asRecord(owner?.projectsV2);
3591
+ const nodes = projects?.nodes;
3592
+ return Array.isArray(nodes) ? nodes : [];
3593
+ }
3594
+ async function listGitHubProjects(input) {
3595
+ const query = `
3596
+ query RigListProjects($owner: String!, $first: Int!) {
3597
+ organization(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
3598
+ user(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
3599
+ }
3600
+ `;
3601
+ const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
3602
+ const data = await fetchGraphQL(query, { owner: input.owner, first: input.first ?? 20 }, input.token);
3603
+ return projectNodesFrom(data).flatMap((node) => {
3604
+ const record = asRecord(node);
3605
+ const id = asString(record?.id);
3606
+ const number = asNumber(record?.number);
3607
+ const title = asString(record?.title);
3608
+ if (!id || number === undefined || !title)
3609
+ return [];
3610
+ return [{ id, number, title, ...asString(record?.url) ? { url: asString(record?.url) } : {} }];
3611
+ });
3612
+ }
3568
3613
  async function resolveProjectStatusField(input) {
3569
3614
  const query = `
3570
3615
  query RigProjectStatusField($projectId: ID!) {
@@ -3659,6 +3704,7 @@ var DEFAULT_PROJECT_STATUSES = {
3659
3704
  running: "In Progress",
3660
3705
  prOpen: "In Review",
3661
3706
  ciFixing: "In Review",
3707
+ merging: "Merging",
3662
3708
  done: "Done",
3663
3709
  needsAttention: "Needs Attention"
3664
3710
  };
@@ -3672,6 +3718,8 @@ function lifecycleStatusForTaskStatus(status) {
3672
3718
  return "prOpen";
3673
3719
  if (normalized === "ci_fixing" || normalized === "fixing")
3674
3720
  return "ciFixing";
3721
+ if (normalized === "merging" || normalized === "merge")
3722
+ return "merging";
3675
3723
  if (normalized === "failed" || normalized === "needs_attention" || normalized === "blocked")
3676
3724
  return "needsAttention";
3677
3725
  if (normalized === "in_progress" || normalized === "running" || normalized === "ready" || normalized === "open")
@@ -3800,9 +3848,14 @@ function parseIssueRef(sourceTask, fallbackTaskId) {
3800
3848
  return null;
3801
3849
  return null;
3802
3850
  }
3851
+ function githubProjectsEnabled(config) {
3852
+ const github = config?.github && typeof config.github === "object" && !Array.isArray(config.github) ? config.github : null;
3853
+ const projects = github?.projects && typeof github.projects === "object" && !Array.isArray(github.projects) ? github.projects : null;
3854
+ return projects?.enabled === true;
3855
+ }
3803
3856
  async function syncProjectStatusForRunLifecycle(projectRoot, run, status, config) {
3804
3857
  if (!run.taskId)
3805
- return;
3858
+ return false;
3806
3859
  const issueNodeId = extractGitHubIssueNodeId(runSourceTaskIdentity(run));
3807
3860
  try {
3808
3861
  const result = await syncGitHubProjectStatusForTaskUpdate({
@@ -3813,28 +3866,86 @@ async function syncProjectStatusForRunLifecycle(projectRoot, run, status, config
3813
3866
  config
3814
3867
  });
3815
3868
  if (!result.synced && result.reason !== "project-sync-disabled") {
3869
+ const detail = `Project status sync for ${run.taskId} could not run: ${result.reason}.`;
3816
3870
  appendRunLogEntry(projectRoot, run.runId, {
3817
3871
  id: `log:${run.runId}:github-project-sync:${status}`,
3818
3872
  title: "GitHub Project sync skipped",
3819
- detail: `Project status sync for ${run.taskId} could not run: ${result.reason}.`,
3873
+ detail,
3820
3874
  tone: "warn",
3821
3875
  status: "running",
3822
3876
  createdAt: new Date().toISOString(),
3823
3877
  payload: { reason: result.reason, issueNodeId }
3824
3878
  });
3879
+ if (githubProjectsEnabled(config)) {
3880
+ throw new Error(detail);
3881
+ }
3882
+ return false;
3825
3883
  }
3884
+ return result.synced === true;
3826
3885
  } catch (error) {
3886
+ const detail = error instanceof Error ? error.message : String(error);
3827
3887
  appendRunLogEntry(projectRoot, run.runId, {
3828
3888
  id: `log:${run.runId}:github-project-sync-error:${status}`,
3829
3889
  title: "GitHub Project sync failed",
3830
- detail: error instanceof Error ? error.message : String(error),
3890
+ detail,
3831
3891
  tone: "error",
3832
3892
  status: "running",
3833
3893
  createdAt: new Date().toISOString(),
3834
3894
  payload: { issueNodeId }
3835
3895
  });
3896
+ if (githubProjectsEnabled(config)) {
3897
+ throw new Error(detail);
3898
+ }
3899
+ return false;
3836
3900
  }
3837
3901
  }
3902
+ function createCommandRunner(binary, extraEnv = {}) {
3903
+ return async (args, options) => {
3904
+ const child = spawn3(binary, [...args], {
3905
+ cwd: options?.cwd,
3906
+ env: { ...process.env, ...extraEnv },
3907
+ stdio: ["ignore", "pipe", "pipe"]
3908
+ });
3909
+ const stdoutChunks = [];
3910
+ const stderrChunks = [];
3911
+ child.stdout?.on("data", (chunk) => stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
3912
+ child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
3913
+ const exitCode = await new Promise((resolve15) => {
3914
+ child.once("error", () => resolve15(1));
3915
+ child.once("close", (code) => resolve15(code ?? 1));
3916
+ });
3917
+ return {
3918
+ exitCode,
3919
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
3920
+ stderr: Buffer.concat(stderrChunks).toString("utf8")
3921
+ };
3922
+ };
3923
+ }
3924
+ function closeoutRecord(run) {
3925
+ const value = run.serverCloseout;
3926
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
3927
+ }
3928
+ function closeoutPhasePatch(phase, status, extra = {}) {
3929
+ const updatedAt = new Date().toISOString();
3930
+ return {
3931
+ serverCloseout: {
3932
+ phase,
3933
+ status,
3934
+ updatedAt,
3935
+ ...extra
3936
+ }
3937
+ };
3938
+ }
3939
+ function appendCloseoutStage(state, runId, phase, detail, status = "reviewing", tone = "info") {
3940
+ appendRunLogEntryAndBroadcast(state, runId, {
3941
+ id: `log:${runId}:server-closeout:${phase}:${Date.now()}`,
3942
+ title: `Server closeout: ${phase}`,
3943
+ detail,
3944
+ tone,
3945
+ status,
3946
+ createdAt: new Date().toISOString()
3947
+ }, `server-closeout-${phase}`);
3948
+ }
3838
3949
  async function autoAssignRunIssue(projectRoot, run) {
3839
3950
  if (!run.taskId)
3840
3951
  return;
@@ -3864,7 +3975,7 @@ async function updateRunTaskSourceLifecycle(projectRoot, run, status, summary, o
3864
3975
  return;
3865
3976
  }
3866
3977
  const config = await loadRigLifecycleConfig(projectRoot);
3867
- await syncProjectStatusForRunLifecycle(projectRoot, run, status, config);
3978
+ const projectSynced = await syncProjectStatusForRunLifecycle(projectRoot, run, status, config);
3868
3979
  if (status === "in_progress") {
3869
3980
  await autoAssignRunIssue(projectRoot, run);
3870
3981
  }
@@ -3880,24 +3991,53 @@ async function updateRunTaskSourceLifecycle(projectRoot, run, status, summary, o
3880
3991
  });
3881
3992
  return;
3882
3993
  }
3883
- const result = await updateConfiguredTaskSourceTask(projectRoot, {
3884
- taskId: run.taskId,
3885
- sourceTask: runSourceTaskIdentity(run),
3886
- update: {
3887
- status,
3888
- comment: buildTaskRunLifecycleComment({
3889
- runId: run.runId,
3994
+ const sourceTask = runSourceTaskIdentity(run);
3995
+ const previousStatus = normalizeString(sourceTask?.status) ?? normalizeString(sourceTask?.sourceStatus);
3996
+ const rollbackProjectSync = async () => {
3997
+ if (!projectSynced || !previousStatus || !run.taskId || !githubProjectsEnabled(config))
3998
+ return;
3999
+ await syncGitHubProjectStatusForTaskUpdate({
4000
+ taskId: run.taskId,
4001
+ status: previousStatus,
4002
+ issueNodeId: extractGitHubIssueNodeId(sourceTask),
4003
+ token: createGitHubAuthStore(projectRoot).readToken(),
4004
+ config
4005
+ }).catch((rollbackError) => {
4006
+ appendRunLogEntry(projectRoot, run.runId, {
4007
+ id: `log:${run.runId}:github-project-sync-rollback:${status}`,
4008
+ title: "GitHub Project sync rollback failed",
4009
+ detail: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
4010
+ tone: "error",
4011
+ status: "running",
4012
+ createdAt: new Date().toISOString()
4013
+ });
4014
+ });
4015
+ };
4016
+ let result;
4017
+ try {
4018
+ result = await updateConfiguredTaskSourceTask(projectRoot, {
4019
+ taskId: run.taskId,
4020
+ sourceTask,
4021
+ update: {
3890
4022
  status,
3891
- summary,
3892
- runtimeWorkspace: normalizeString(run.worktreePath),
3893
- logsDir: normalizeString(run.logRoot),
3894
- sessionDir: normalizeString(run.sessionPath),
3895
- errorText: options.errorText ?? normalizeString(run.errorText)
3896
- })
3897
- }
3898
- });
4023
+ comment: buildTaskRunLifecycleComment({
4024
+ runId: run.runId,
4025
+ status,
4026
+ summary,
4027
+ runtimeWorkspace: normalizeString(run.worktreePath),
4028
+ logsDir: normalizeString(run.logRoot),
4029
+ sessionDir: normalizeString(run.sessionPath),
4030
+ errorText: options.errorText ?? normalizeString(run.errorText)
4031
+ })
4032
+ }
4033
+ });
4034
+ } catch (error) {
4035
+ await rollbackProjectSync();
4036
+ throw error;
4037
+ }
3899
4038
  if (!result.updated) {
3900
4039
  if (result.source === "plugin" || result.sourceKind) {
4040
+ await rollbackProjectSync();
3901
4041
  throw new Error(`Configured task source${result.sourceKind ? ` (${result.sourceKind})` : ""} did not accept lifecycle update for ${result.taskId}.`);
3902
4042
  }
3903
4043
  appendRunLogEntry(projectRoot, run.runId, {
@@ -3910,6 +4050,219 @@ async function updateRunTaskSourceLifecycle(projectRoot, run, status, summary, o
3910
4050
  });
3911
4051
  }
3912
4052
  }
4053
+ async function runServerOwnedPrCloseout(state, runId) {
4054
+ const run = readAuthorityRun4(state.projectRoot, runId);
4055
+ if (!run)
4056
+ throw new Error(`Run not found: ${runId}`);
4057
+ const closeout = closeoutRecord(run);
4058
+ if (!closeout)
4059
+ return;
4060
+ const taskId = normalizeString(closeout.taskId) ?? normalizeString(run.taskId);
4061
+ if (!taskId)
4062
+ throw new Error("Server-owned closeout requires a task id.");
4063
+ const workspace = normalizeString(closeout.runtimeWorkspace) ?? normalizeString(run.worktreePath) ?? state.projectRoot;
4064
+ const branch = normalizeString(closeout.branch) ?? `rig/${taskId}-${runId}`;
4065
+ const config = await loadRigLifecycleConfig(state.projectRoot);
4066
+ const runPrMode = normalizeString(run.prMode);
4067
+ const prMode = runPrMode === "auto" || runPrMode === "ask" || runPrMode === "off" ? runPrMode : config?.pr?.mode ?? "off";
4068
+ const effectiveConfig = {
4069
+ ...config ?? {},
4070
+ pr: {
4071
+ ...config?.pr ?? {},
4072
+ mode: prMode,
4073
+ autoFixChecks: false,
4074
+ autoFixReview: false
4075
+ }
4076
+ };
4077
+ const readCurrentRun = () => readAuthorityRun4(state.projectRoot, runId) ?? run;
4078
+ const sourceTask = runSourceTaskIdentity(run);
4079
+ const closeoutPhase = normalizeString(closeout.phase)?.toLowerCase() ?? "";
4080
+ const closeoutStatus = normalizeString(closeout.status)?.toLowerCase() ?? "";
4081
+ const closeoutPrUrl = normalizeString(closeout.prUrl);
4082
+ if (closeoutPhase === "completed" || closeoutStatus === "completed") {
4083
+ return;
4084
+ }
4085
+ if (closeoutPhase === "close-source" && closeoutPrUrl) {
4086
+ patchRunRecord(state.projectRoot, runId, {
4087
+ status: "reviewing",
4088
+ ...closeoutPhasePatch("close-source", "running", { ...closeout, prUrl: closeoutPrUrl, taskId, runtimeWorkspace: workspace, branch })
4089
+ });
4090
+ await closeIssueAfterMergedPr({
4091
+ projectRoot: state.projectRoot,
4092
+ taskId,
4093
+ runId,
4094
+ prUrl: closeoutPrUrl,
4095
+ sourceTask,
4096
+ updateTaskSource: async (projectRoot, input) => {
4097
+ await updateRunTaskSourceLifecycle(projectRoot, readCurrentRun(), "closed", "Rig merged the pull request and closed this task source.");
4098
+ return { updated: true, taskId: input.taskId, status: input.update.status, source: "server", sourceKind: "server" };
4099
+ }
4100
+ });
4101
+ const completedAt = new Date().toISOString();
4102
+ patchRunRecord(state.projectRoot, runId, {
4103
+ status: "completed",
4104
+ completedAt,
4105
+ errorText: null,
4106
+ ...closeoutPhasePatch("completed", "completed", { ...closeout, prUrl: closeoutPrUrl, iterations: closeout.iterations, completedAt })
4107
+ });
4108
+ appendCloseoutStage(state, runId, "completed", `PR merged and issue closed: ${closeoutPrUrl}`, "completed", "info");
4109
+ emitRigEvent(state, {
4110
+ type: "rig.run.completed",
4111
+ aggregateId: runId,
4112
+ payload: { runId, taskId, prUrl: closeoutPrUrl, closeout: "merged" },
4113
+ createdAt: completedAt
4114
+ });
4115
+ return;
4116
+ }
4117
+ if (prMode === "off" || prMode === "ask") {
4118
+ const completedAt = new Date().toISOString();
4119
+ patchRunRecord(state.projectRoot, runId, {
4120
+ status: "completed",
4121
+ completedAt,
4122
+ errorText: null,
4123
+ ...closeoutPhasePatch("completed", "completed", { taskId, runtimeWorkspace: workspace, branch, reason: prMode === "ask" ? "pr-mode-ask" : "pr-mode-off" })
4124
+ });
4125
+ appendCloseoutStage(state, runId, "completed", prMode === "ask" ? "Validation completed; PR creation awaits operator approval." : "Validation completed; PR automation disabled.", "completed", "info");
4126
+ emitRigEvent(state, {
4127
+ type: "rig.run.completed",
4128
+ aggregateId: runId,
4129
+ payload: { runId, taskId, closeout: "skipped", reason: prMode === "ask" ? "pr-mode-ask" : "pr-mode-off" },
4130
+ createdAt: completedAt
4131
+ });
4132
+ return;
4133
+ }
4134
+ const githubToken = createGitHubAuthStore(state.projectRoot).readToken();
4135
+ const githubEnv = githubToken ? { RIG_GITHUB_TOKEN: githubToken, GITHUB_TOKEN: githubToken, GH_TOKEN: githubToken } : {};
4136
+ const gitCommand = createCommandRunner("git", githubEnv);
4137
+ const ghCommand = createCommandRunner("gh", githubEnv);
4138
+ const setCloseout = (phase, status, extra = {}) => {
4139
+ const previous = closeoutRecord(readCurrentRun()) ?? closeout;
4140
+ patchRunRecord(state.projectRoot, runId, {
4141
+ status: status === "failed" ? "failed" : status === "needs_attention" ? "needs_attention" : "reviewing",
4142
+ ...closeoutPhasePatch(phase, status, { ...previous, ...extra })
4143
+ });
4144
+ };
4145
+ setCloseout("commit", "running", { runtimeWorkspace: workspace, branch, taskId });
4146
+ appendCloseoutStage(state, runId, "commit", `Committing changes in ${workspace}.`, "reviewing", "tool");
4147
+ const commit = await commitRunChanges({ cwd: workspace, message: `rig: complete task ${taskId}`, command: gitCommand });
4148
+ appendCloseoutStage(state, runId, "commit", commit.committed ? "Committed run workspace changes." : "No workspace changes to commit.", "reviewing", "tool");
4149
+ setCloseout("push", "running", { runtimeWorkspace: workspace, branch, taskId });
4150
+ const push = await gitCommand(["push", "--set-upstream", "origin", branch], { cwd: workspace });
4151
+ if (push.exitCode !== 0) {
4152
+ throw new Error(`git push --set-upstream origin ${branch} failed (${push.exitCode}): ${push.stderr ?? push.stdout ?? ""}`.trim());
4153
+ }
4154
+ const sourceTaskForPr = {
4155
+ title: normalizeString(sourceTask?.title) ?? normalizeString(run.title)
4156
+ };
4157
+ const artifactRoot = resolve14(state.projectRoot, "artifacts", taskId);
4158
+ setCloseout("pr-review-merge", "running", { runtimeWorkspace: workspace, branch, taskId, artifactRoot });
4159
+ const pr = await runPrAutomation({
4160
+ projectRoot: workspace,
4161
+ taskId,
4162
+ runId,
4163
+ branch,
4164
+ config: effectiveConfig,
4165
+ sourceTask: sourceTaskForPr,
4166
+ artifactRoot,
4167
+ command: ghCommand,
4168
+ gitCommand,
4169
+ steerPi: async (message) => {
4170
+ appendCloseoutStage(state, runId, "feedback", message, "reviewing", "info");
4171
+ appendRunTimelineEntry(state.projectRoot, runId, {
4172
+ id: `message:${runId}:server-closeout-feedback:${Date.now()}`,
4173
+ type: "user_message",
4174
+ text: message,
4175
+ createdAt: new Date().toISOString(),
4176
+ state: "completed"
4177
+ });
4178
+ },
4179
+ lifecycle: {
4180
+ onPrOpened: async ({ prUrl }) => {
4181
+ setCloseout("pr-opened", "running", { prUrl, runtimeWorkspace: workspace, branch, taskId, artifactRoot });
4182
+ appendCloseoutStage(state, runId, "open-pr", prUrl, "reviewing", "tool");
4183
+ await updateRunTaskSourceLifecycle(state.projectRoot, readCurrentRun(), "under_review", "Rig opened a pull request for this task.");
4184
+ },
4185
+ onReviewCiStarted: ({ prUrl, iteration }) => appendCloseoutStage(state, runId, "review-ci", `${prUrl} (iteration ${iteration})`, "reviewing", "info"),
4186
+ onFeedback: async ({ feedback }) => {
4187
+ appendCloseoutStage(state, runId, "feedback", feedback.join(`
4188
+ `), "reviewing", "error");
4189
+ await updateRunTaskSourceLifecycle(state.projectRoot, readCurrentRun(), "ci_fixing", "Rig is fixing CI/review feedback for this task.");
4190
+ },
4191
+ onMergeStarted: async ({ prUrl }) => {
4192
+ setCloseout("merge", "running", { prUrl, runtimeWorkspace: workspace, branch, taskId, artifactRoot });
4193
+ appendCloseoutStage(state, runId, "merge", prUrl, "reviewing", "tool");
4194
+ await updateRunTaskSourceLifecycle(state.projectRoot, readCurrentRun(), "merging", "Rig is merging the pull request for this task.");
4195
+ },
4196
+ onMerged: ({ prUrl }) => {
4197
+ setCloseout("close-source", "running", { prUrl, runtimeWorkspace: workspace, branch, taskId, artifactRoot, merged: true });
4198
+ appendCloseoutStage(state, runId, "merge", prUrl, "reviewing", "tool");
4199
+ }
4200
+ }
4201
+ });
4202
+ if (pr.status === "merged" && pr.prUrl) {
4203
+ setCloseout("close-source", "running", { prUrl: pr.prUrl, iterations: pr.iterations });
4204
+ await closeIssueAfterMergedPr({
4205
+ projectRoot: state.projectRoot,
4206
+ taskId,
4207
+ runId,
4208
+ prUrl: pr.prUrl,
4209
+ sourceTask,
4210
+ updateTaskSource: async (projectRoot, input) => {
4211
+ await updateRunTaskSourceLifecycle(projectRoot, readCurrentRun(), "closed", "Rig merged the pull request and closed this task source.");
4212
+ return { updated: true, taskId: input.taskId, status: input.update.status, source: "server", sourceKind: "server" };
4213
+ }
4214
+ });
4215
+ const completedAt = new Date().toISOString();
4216
+ patchRunRecord(state.projectRoot, runId, {
4217
+ status: "completed",
4218
+ completedAt,
4219
+ errorText: null,
4220
+ ...closeoutPhasePatch("completed", "completed", { prUrl: pr.prUrl, iterations: pr.iterations, completedAt })
4221
+ });
4222
+ appendCloseoutStage(state, runId, "completed", `PR merged and issue closed: ${pr.prUrl}`, "completed", "info");
4223
+ emitRigEvent(state, {
4224
+ type: "rig.run.completed",
4225
+ aggregateId: runId,
4226
+ payload: { runId, taskId, prUrl: pr.prUrl, closeout: "merged" },
4227
+ createdAt: completedAt
4228
+ });
4229
+ return;
4230
+ }
4231
+ if (pr.status === "opened" && pr.prUrl) {
4232
+ const completedAt = new Date().toISOString();
4233
+ patchRunRecord(state.projectRoot, runId, {
4234
+ status: "completed",
4235
+ completedAt,
4236
+ errorText: null,
4237
+ ...closeoutPhasePatch("completed", "completed", { prUrl: pr.prUrl, iterations: pr.iterations })
4238
+ });
4239
+ appendCloseoutStage(state, runId, "completed", `PR ready without merge: ${pr.prUrl}`, "completed", "info");
4240
+ emitRigEvent(state, {
4241
+ type: "rig.run.completed",
4242
+ aggregateId: runId,
4243
+ payload: { runId, taskId, prUrl: pr.prUrl, closeout: "pr-ready" },
4244
+ createdAt: completedAt
4245
+ });
4246
+ return;
4247
+ }
4248
+ const detail = pr.actionableFeedback.join(`
4249
+ `) || "PR automation did not merge the PR.";
4250
+ await updateRunTaskSourceLifecycle(state.projectRoot, readCurrentRun(), "needs_attention", "Rig needs operator attention before this task can proceed.", { errorText: detail }).catch((error) => {
4251
+ appendCloseoutStage(state, runId, "needs-attention-update", error instanceof Error ? error.message : String(error), "needs_attention", "error");
4252
+ });
4253
+ patchRunRecord(state.projectRoot, runId, {
4254
+ status: "needs_attention",
4255
+ completedAt: new Date().toISOString(),
4256
+ errorText: detail,
4257
+ ...closeoutPhasePatch("needs_attention", "needs_attention", { feedback: pr.actionableFeedback, prUrl: pr.prUrl ?? null, iterations: pr.iterations })
4258
+ });
4259
+ appendCloseoutStage(state, runId, "needs-attention", detail, "needs_attention", "error");
4260
+ emitRigEvent(state, {
4261
+ type: "rig.run.needs-attention",
4262
+ aggregateId: runId,
4263
+ payload: { runId, taskId, error: detail, prUrl: pr.prUrl ?? null }
4264
+ });
4265
+ }
3913
4266
  var TERMINAL_RUN_STATUSES2 = new Set([
3914
4267
  "completed",
3915
4268
  "complete",
@@ -4118,6 +4471,7 @@ async function startLocalRun(state, runId, options) {
4118
4471
  RIG_HOST_PROJECT_ROOT: cliProjectRoot,
4119
4472
  RIG_RUNTIME_BASE_REF: process.env.RIG_RUNTIME_BASE_REF ?? "HEAD",
4120
4473
  RIG_SERVER_INTERNAL_EXEC: "1",
4474
+ RIG_SERVER_OWNS_CLOSEOUT: "1",
4121
4475
  ...serverUrl ? { RIG_SERVER_URL: serverUrl } : {},
4122
4476
  ...bridgeAuthToken ? { RIG_AUTH_TOKEN: bridgeAuthToken } : {},
4123
4477
  ...bridgeGitHubToken ? {
@@ -4214,6 +4568,38 @@ ${sourceFailure}` });
4214
4568
  agent: current.runtimeAdapter,
4215
4569
  summary: failureSummary
4216
4570
  });
4571
+ } else if (closeoutRecord(current)?.status === "pending") {
4572
+ try {
4573
+ await runServerOwnedPrCloseout(state, runId);
4574
+ } catch (closeoutError) {
4575
+ const closeoutFailure = closeoutError instanceof Error ? closeoutError.message : String(closeoutError);
4576
+ patchRunRecord(state.projectRoot, runId, {
4577
+ status: "failed",
4578
+ completedAt: new Date().toISOString(),
4579
+ errorText: closeoutFailure,
4580
+ ...closeoutPhasePatch("failed", "failed", { error: closeoutFailure })
4581
+ });
4582
+ appendRunLogEntryAndBroadcast(state, runId, {
4583
+ id: `log:${runId}:server-closeout-failed`,
4584
+ title: "Server-owned closeout failed",
4585
+ detail: closeoutFailure,
4586
+ tone: "error",
4587
+ status: "failed",
4588
+ createdAt: new Date().toISOString()
4589
+ }, "server-closeout-failed");
4590
+ if (current.taskId) {
4591
+ await updateRunTaskSourceLifecycle(state.projectRoot, { ...current, status: "failed", errorText: closeoutFailure }, "failed", "Rig server-owned closeout failed.", { errorText: closeoutFailure }).catch((error) => {
4592
+ appendRunLogEntry(state.projectRoot, runId, {
4593
+ id: `log:${runId}:task-source-closeout-failed-update`,
4594
+ title: "Task source closeout failure update failed",
4595
+ detail: error instanceof Error ? error.message : String(error),
4596
+ tone: "error",
4597
+ status: "failed",
4598
+ createdAt: new Date().toISOString()
4599
+ });
4600
+ });
4601
+ }
4602
+ }
4217
4603
  }
4218
4604
  broadcastSnapshotInvalidation(state);
4219
4605
  } catch (error) {
@@ -4300,6 +4686,12 @@ async function resumeRunRecord(state, input) {
4300
4686
  if (run.status === "completed") {
4301
4687
  throw new Error("Completed runs cannot be resumed.");
4302
4688
  }
4689
+ const closeout = closeoutRecord(run);
4690
+ const closeoutStatus = normalizeString(closeout?.status)?.toLowerCase() ?? "";
4691
+ if (RESUMABLE_SERVER_CLOSEOUT_STATUSES.has(closeoutStatus)) {
4692
+ await runServerOwnedPrCloseout(state, input.runId);
4693
+ return;
4694
+ }
4303
4695
  await startLocalRun(state, input.runId, { promptOverride: input.promptOverride ?? null, resume: input.restart !== true });
4304
4696
  }
4305
4697
  function appendRunMessage(projectRoot, input) {
@@ -4384,11 +4776,45 @@ function removeTaskIdsFromQueueState(projectRoot, taskIds) {
4384
4776
  writeQueueState(projectRoot, next);
4385
4777
  return next;
4386
4778
  }
4387
- var RESUMABLE_LOCAL_RUN_STATUSES = new Set(["created", "preparing", "running", "validating", "reviewing"]);
4388
- function collectResumableLocalRuns(state, runs) {
4779
+ var RESUMABLE_SERVER_CLOSEOUT_STATUSES = new Set(["pending", "running"]);
4780
+ var ACTIVE_LOCAL_RUN_STATUSES = new Set(["created", "preparing", "running", "validating", "reviewing"]);
4781
+ function processExists(pid) {
4782
+ if (!Number.isInteger(pid) || pid <= 0)
4783
+ return false;
4784
+ try {
4785
+ process.kill(pid, 0);
4786
+ return true;
4787
+ } catch {
4788
+ return false;
4789
+ }
4790
+ }
4791
+ function recoverStaleLocalRun(projectRoot, run) {
4792
+ const record = run;
4793
+ if (run.mode !== "local")
4794
+ return false;
4795
+ const status = normalizeString(record.status)?.toLowerCase() ?? "";
4796
+ if (!ACTIVE_LOCAL_RUN_STATUSES.has(status))
4797
+ return false;
4798
+ const serverPid = typeof record.serverPid === "number" ? record.serverPid : null;
4799
+ const childPid = typeof record.pid === "number" ? record.pid : null;
4800
+ if (serverPid === null && childPid === null)
4801
+ return false;
4802
+ const hasLiveRecordedProcess = [serverPid, childPid].some((pid) => typeof pid === "number" && processExists(pid));
4803
+ if (hasLiveRecordedProcess && serverPid === process.pid)
4804
+ return false;
4805
+ const completedAt = new Date().toISOString();
4806
+ patchRunRecord(projectRoot, run.runId, {
4807
+ status: "failed",
4808
+ completedAt,
4809
+ errorText: `Recovered stale local run ${run.runId} after server startup; no active server-owned process was tracking it.`
4810
+ });
4811
+ return true;
4812
+ }
4813
+ function collectResumableServerCloseouts(state, runs) {
4389
4814
  return runs.filter((run) => {
4390
- const status = normalizeString(run.status)?.toLowerCase() ?? "";
4391
- return run.mode === "local" && RESUMABLE_LOCAL_RUN_STATUSES.has(status) && !state.runProcesses.has(run.runId);
4815
+ const closeout = closeoutRecord(run);
4816
+ const closeoutStatus = normalizeString(closeout?.status)?.toLowerCase() ?? "";
4817
+ return run.mode === "local" && RESUMABLE_SERVER_CLOSEOUT_STATUSES.has(closeoutStatus) && !state.runProcesses.has(run.runId);
4392
4818
  });
4393
4819
  }
4394
4820
  async function reconcileScheduler(state, reason) {
@@ -4405,17 +4831,33 @@ async function reconcileScheduler(state, reason) {
4405
4831
  const tasks = await state.snapshotService.getWorkspaceTasks();
4406
4832
  let runs = listAuthorityRuns4(state.projectRoot);
4407
4833
  let changed = false;
4408
- const resumableRuns = collectResumableLocalRuns(state, runs);
4409
- for (const run of resumableRuns) {
4834
+ for (const run of runs) {
4835
+ if (!state.runProcesses.has(run.runId) && recoverStaleLocalRun(state.projectRoot, run)) {
4836
+ changed = true;
4837
+ }
4838
+ }
4839
+ if (changed) {
4840
+ runs = listAuthorityRuns4(state.projectRoot);
4841
+ }
4842
+ const resumableCloseouts = collectResumableServerCloseouts(state, runs);
4843
+ for (const run of resumableCloseouts) {
4410
4844
  appendRunLogEntry(state.projectRoot, run.runId, {
4411
- id: `log:${run.runId}:auto-resume:${Date.now()}`,
4412
- title: "Run auto-resume scheduled",
4413
- detail: `Rig server recovered nonterminal run ${run.runId} after ${reason}; resuming the same lifecycle instead of restarting it.`,
4845
+ id: `log:${run.runId}:server-closeout-auto-resume:${Date.now()}`,
4846
+ title: "Server-owned closeout auto-resume scheduled",
4847
+ detail: `Rig server recovered closeout checkpoint ${run.runId} after ${reason}; resuming the server-owned lifecycle phase.`,
4414
4848
  tone: "info",
4415
- status: "preparing",
4849
+ status: "reviewing",
4416
4850
  createdAt: new Date().toISOString()
4417
4851
  });
4418
- await startLocalRun(state, run.runId, { resume: true });
4852
+ await runServerOwnedPrCloseout(state, run.runId).catch((error) => {
4853
+ const detail = error instanceof Error ? error.message : String(error);
4854
+ patchRunRecord(state.projectRoot, run.runId, {
4855
+ status: "failed",
4856
+ completedAt: new Date().toISOString(),
4857
+ errorText: detail,
4858
+ ...closeoutPhasePatch("failed", "failed", { error: detail })
4859
+ });
4860
+ });
4419
4861
  changed = true;
4420
4862
  }
4421
4863
  if (changed) {
@@ -5211,6 +5653,75 @@ function buildProjectConfigStatus(root) {
5211
5653
  suggestion: kind === "missing" ? "Run `rig init` in the project root to scaffold rig.config.ts." : null
5212
5654
  };
5213
5655
  }
5656
+ var RIG_GITHUB_LIFECYCLE_LABELS = [
5657
+ "ready",
5658
+ "blocked",
5659
+ "in-progress",
5660
+ "under-review",
5661
+ "failed",
5662
+ "cancelled",
5663
+ "rig:running",
5664
+ "rig:pr-open",
5665
+ "rig:ci-fixing",
5666
+ "rig:merging",
5667
+ "rig:done",
5668
+ "rig:needs-attention"
5669
+ ];
5670
+ function githubProjectsEnabled2(config) {
5671
+ if (!config || typeof config !== "object" || Array.isArray(config))
5672
+ return false;
5673
+ const root = config;
5674
+ const github = root.github && typeof root.github === "object" && !Array.isArray(root.github) ? root.github : null;
5675
+ const projects = github?.projects && typeof github.projects === "object" && !Array.isArray(github.projects) ? github.projects : null;
5676
+ return projects?.enabled === true;
5677
+ }
5678
+ function githubIssueSourceRepo(config) {
5679
+ if (!config || typeof config !== "object" || Array.isArray(config))
5680
+ return null;
5681
+ const root = config;
5682
+ const taskSource = root.taskSource && typeof root.taskSource === "object" && !Array.isArray(root.taskSource) ? root.taskSource : null;
5683
+ const owner = normalizeString(taskSource?.owner);
5684
+ const repo = normalizeString(taskSource?.repo);
5685
+ if (taskSource?.kind === "github-issues" && owner && repo)
5686
+ return { owner, repo };
5687
+ const project = root.project && typeof root.project === "object" && !Array.isArray(root.project) ? root.project : null;
5688
+ const slug = normalizeString(project?.repo) ?? normalizeString(project?.name);
5689
+ const match = slug?.match(/^([^/]+)\/([^/]+)$/);
5690
+ return match ? { owner: match[1], repo: match[2] } : null;
5691
+ }
5692
+ async function ensureGitHubLifecycleLabels(projectRoot, config) {
5693
+ const repo = githubIssueSourceRepo(config);
5694
+ if (!repo)
5695
+ return { ok: false, ready: false, labelsReady: false, reason: "not-github-issues-source", labels: RIG_GITHUB_LIFECYCLE_LABELS };
5696
+ const token = createGitHubAuthStore(projectRoot).readToken();
5697
+ if (!token)
5698
+ return { ok: false, ready: false, labelsReady: false, reason: "missing-token", repo, labels: RIG_GITHUB_LIFECYCLE_LABELS };
5699
+ const existingResponse = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.repo}/labels?per_page=100`, {
5700
+ headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "user-agent": "rig-server" }
5701
+ });
5702
+ const existingJson = await existingResponse.json().catch(() => []);
5703
+ const existing = new Set(Array.isArray(existingJson) ? existingJson.flatMap((entry) => entry && typeof entry === "object" && typeof entry.name === "string" ? [entry.name] : []) : []);
5704
+ const created = [];
5705
+ const alreadyPresent = [];
5706
+ const failed = [];
5707
+ for (const label of RIG_GITHUB_LIFECYCLE_LABELS) {
5708
+ if (existing.has(label)) {
5709
+ alreadyPresent.push(label);
5710
+ continue;
5711
+ }
5712
+ const response = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.repo}/labels`, {
5713
+ method: "POST",
5714
+ headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "rig-server" },
5715
+ body: JSON.stringify({ name: label, color: label.startsWith("rig:") ? "6f42c1" : "ededed", description: label.startsWith("rig:") ? "Task status managed by Rig" : "Task lifecycle status managed by Rig" })
5716
+ });
5717
+ if (response.ok || response.status === 422) {
5718
+ (response.status === 422 ? alreadyPresent : created).push(label);
5719
+ } else {
5720
+ failed.push({ label, error: await response.text().catch(() => response.statusText) });
5721
+ }
5722
+ }
5723
+ return { ok: failed.length === 0, ready: failed.length === 0, labelsReady: failed.length === 0, repo, labels: RIG_GITHUB_LIFECYCLE_LABELS, created, existing: alreadyPresent, failed };
5724
+ }
5214
5725
  function normalizeCommit(value) {
5215
5726
  const raw = normalizeString(value);
5216
5727
  return raw && /^[0-9a-f]{7,40}$/i.test(raw) ? raw : null;
@@ -5609,15 +6120,33 @@ say "Installing @h-rig/cli@latest"
5609
6120
  bun add -g @h-rig/cli@latest
5610
6121
 
5611
6122
  export BUN_INSTALL="\${BUN_INSTALL:-$HOME/.bun}"
5612
- export PATH="$BUN_INSTALL/bin:$PATH"
6123
+ BUN_RIG="$BUN_INSTALL/bin/rig"
6124
+ if [ ! -x "$BUN_RIG" ]; then
6125
+ printf 'rig-install: expected Bun global rig at %s but it was not executable.
6126
+ ' "$BUN_RIG" >&2
6127
+ exit 1
6128
+ fi
6129
+
6130
+ USER_BIN="$HOME/.local/bin"
6131
+ mkdir -p "$USER_BIN"
6132
+ cat > "$USER_BIN/rig" <<'RIG_SHIM'
6133
+ #!/usr/bin/env bash
6134
+ set -euo pipefail
6135
+ exec "\${BUN_INSTALL:-$HOME/.bun}/bin/rig" "$@"
6136
+ RIG_SHIM
6137
+ chmod +x "$USER_BIN/rig"
6138
+
6139
+ export PATH="$USER_BIN:$BUN_INSTALL/bin:$PATH"
6140
+ if command -v hash >/dev/null 2>&1; then hash -r; fi
5613
6141
 
5614
6142
  if ! command -v rig >/dev/null 2>&1; then
5615
- printf 'rig-install: rig installed, but rig is not on PATH. Add %s/bin to PATH and retry.
5616
- ' "$BUN_INSTALL" >&2
6143
+ printf 'rig-install: rig installed, but rig is not on PATH. Add %s and %s/bin to PATH and retry.
6144
+ ' "$USER_BIN" "$BUN_INSTALL" >&2
5617
6145
  exit 1
5618
6146
  fi
5619
6147
 
5620
6148
  say "Verifying rig"
6149
+ "$BUN_RIG" --help >/dev/null
5621
6150
  rig --help >/dev/null
5622
6151
  say "Done. Run: rig --help"
5623
6152
  `;
@@ -6356,16 +6885,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
6356
6885
  if (!source) {
6357
6886
  return deps.badRequest("No task source is configured");
6358
6887
  }
6888
+ if (!source.updateTask && !(update.status && source.updateStatus)) {
6889
+ return deps.badRequest("Configured task source does not support updates");
6890
+ }
6359
6891
  const taskBeforeUpdate = source.get ? await source.get(id).catch(() => {
6360
6892
  return;
6361
6893
  }) : (await deps.snapshotService.getWorkspaceTasks().catch(() => [])).find((task) => task.id === id);
6362
- if (source.updateTask) {
6363
- await source.updateTask(id, update);
6364
- } else if (update.status && source.updateStatus) {
6365
- await source.updateStatus(id, update.status);
6366
- } else {
6367
- return deps.badRequest("Configured task source does not support updates");
6368
- }
6369
6894
  const issueNodeId = normalizeString(body.issueNodeId) ?? extractGitHubIssueNodeId(taskBeforeUpdate);
6370
6895
  const projectSync = update.status ? await syncGitHubProjectStatusForTaskUpdate({
6371
6896
  taskId: id,
@@ -6374,6 +6899,35 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
6374
6899
  token: createGitHubAuthStore(state.projectRoot).readToken(),
6375
6900
  config: ctx?.config
6376
6901
  }).catch((error) => ({ synced: false, reason: `error:${error instanceof Error ? error.message : String(error)}` })) : { synced: false, reason: "missing-status" };
6902
+ if (update.status && githubProjectsEnabled2(ctx?.config) && projectSync.synced === false) {
6903
+ return deps.jsonResponse({ ok: false, id, projectSync, error: `GitHub Project status sync failed: ${String(projectSync.reason)}` }, 502);
6904
+ }
6905
+ try {
6906
+ if (source.updateTask) {
6907
+ await source.updateTask(id, update);
6908
+ } else if (update.status && source.updateStatus) {
6909
+ await source.updateStatus(id, update.status);
6910
+ }
6911
+ } catch (error) {
6912
+ let rollback = null;
6913
+ const previousStatus = normalizeString(taskBeforeUpdate?.status) ?? normalizeString(taskBeforeUpdate?.sourceStatus);
6914
+ if (update.status && previousStatus && githubProjectsEnabled2(ctx?.config) && projectSync.synced !== false) {
6915
+ rollback = await syncGitHubProjectStatusForTaskUpdate({
6916
+ taskId: id,
6917
+ status: previousStatus,
6918
+ issueNodeId,
6919
+ token: createGitHubAuthStore(state.projectRoot).readToken(),
6920
+ config: ctx?.config
6921
+ }).catch((rollbackError) => ({ synced: false, reason: `rollback-error:${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}` }));
6922
+ }
6923
+ return deps.jsonResponse({
6924
+ ok: false,
6925
+ id,
6926
+ projectSync,
6927
+ rollback,
6928
+ error: `Task source update failed: ${error instanceof Error ? error.message : String(error)}`
6929
+ }, 502);
6930
+ }
6377
6931
  deps.snapshotService.invalidate("github-issue-updated");
6378
6932
  await state.taskProjectionReconciler?.tick("github-issue-updated").catch(() => {
6379
6933
  return;
@@ -6382,26 +6936,41 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
6382
6936
  return deps.jsonResponse({ ok: true, id, projectSync });
6383
6937
  }
6384
6938
  if (url.pathname === "/api/workspace/task-labels") {
6939
+ const ctx = await getCachedPluginHostContext(state.projectRoot).catch(() => null);
6940
+ if (url.searchParams.get("ensure") === "1" || req.method === "POST") {
6941
+ return deps.jsonResponse(await ensureGitHubLifecycleLabels(state.projectRoot, ctx?.config));
6942
+ }
6385
6943
  return deps.jsonResponse({
6386
6944
  ok: true,
6387
6945
  ready: true,
6388
6946
  labelsReady: true,
6389
- labels: [
6390
- "ready",
6391
- "blocked",
6392
- "in-progress",
6393
- "under-review",
6394
- "failed",
6395
- "cancelled",
6396
- "rig:running",
6397
- "rig:pr-open",
6398
- "rig:ci-fixing",
6399
- "rig:done",
6400
- "rig:needs-attention"
6401
- ],
6402
- note: "GitHub issue lifecycle labels are created on demand by the configured task source when supported."
6947
+ labels: [...RIG_GITHUB_LIFECYCLE_LABELS],
6948
+ note: "Lifecycle labels are required during init; call POST /api/workspace/task-labels or ?ensure=1 to proactively create them."
6403
6949
  });
6404
6950
  }
6951
+ if (url.pathname === "/api/github/projects" && req.method === "GET") {
6952
+ const owner = normalizeString(url.searchParams.get("owner"));
6953
+ if (!owner)
6954
+ return deps.badRequest("owner is required");
6955
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
6956
+ if (!token)
6957
+ return deps.jsonResponse({ ok: false, error: "missing-token", projects: [] }, 401);
6958
+ const projects = await listGitHubProjects({ owner, token }).catch((error) => {
6959
+ throw new Error(error instanceof Error ? error.message : String(error));
6960
+ });
6961
+ return deps.jsonResponse({ ok: true, projects });
6962
+ }
6963
+ const projectStatusMatch = url.pathname.match(/^\/api\/github\/projects\/([^/]+)\/status-field$/);
6964
+ if (projectStatusMatch && req.method === "GET") {
6965
+ const projectId = decodeURIComponent(projectStatusMatch[1]);
6966
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
6967
+ if (!token)
6968
+ return deps.jsonResponse({ ok: false, error: "missing-token" }, 401);
6969
+ const field = await resolveProjectStatusField({ projectId, token }).catch((error) => {
6970
+ throw new Error(error instanceof Error ? error.message : String(error));
6971
+ });
6972
+ return deps.jsonResponse({ ok: true, field });
6973
+ }
6405
6974
  if (url.pathname === "/api/workspace/issue-analysis/run" && req.method === "POST") {
6406
6975
  const body = await deps.readJsonBody(req);
6407
6976
  const ids = uniqueStringList(body.ids ?? body.id);
@@ -7566,6 +8135,69 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
7566
8135
  }
7567
8136
  const run = leaseValidation.run;
7568
8137
  const completedAt = new Date().toISOString();
8138
+ const workspaceDir = normalizeString(body.workspaceDir) ?? normalizeString(body.runtimeWorkspace) ?? normalizeString(run.worktreePath);
8139
+ if (run.taskId && workspaceDir) {
8140
+ patchRunRecord(state.projectRoot, runId, {
8141
+ status: "reviewing",
8142
+ completedAt: null,
8143
+ hostId,
8144
+ endpointId: leaseId,
8145
+ worktreePath: workspaceDir,
8146
+ serverCloseout: {
8147
+ status: "pending",
8148
+ phase: "queued",
8149
+ requestedAt: completedAt,
8150
+ updatedAt: completedAt,
8151
+ runtimeWorkspace: workspaceDir,
8152
+ branch: normalizeString(body.branch) ?? normalizeString(run.branch) ?? `rig/${run.taskId}-${runId}`,
8153
+ taskId: run.taskId,
8154
+ source: "remote-complete"
8155
+ }
8156
+ });
8157
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
8158
+ id: `log:${runId}:remote-server-closeout-requested`,
8159
+ title: "Server-owned closeout requested",
8160
+ detail: "Remote run completed provider work and handed commit/PR/review/merge closeout to the Rig server.",
8161
+ tone: "info",
8162
+ status: "reviewing",
8163
+ createdAt: completedAt,
8164
+ payload: { workspaceDir, hostId, leaseId }
8165
+ }, "remote-server-closeout-requested");
8166
+ deps.runServerOwnedPrCloseout(state, runId).catch((error) => {
8167
+ const detail = error instanceof Error ? error.message : String(error);
8168
+ patchRunRecord(state.projectRoot, runId, {
8169
+ status: "failed",
8170
+ completedAt: new Date().toISOString(),
8171
+ errorText: detail,
8172
+ serverCloseout: {
8173
+ status: "failed",
8174
+ phase: "failed",
8175
+ updatedAt: new Date().toISOString(),
8176
+ error: detail
8177
+ }
8178
+ });
8179
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
8180
+ id: `log:${runId}:remote-server-closeout-failed`,
8181
+ title: "Server-owned closeout failed",
8182
+ detail,
8183
+ tone: "error",
8184
+ status: "failed",
8185
+ createdAt: new Date().toISOString()
8186
+ }, "remote-server-closeout-failed");
8187
+ }).finally(() => {
8188
+ deps.reconcileScheduler(state, "remote-server-closeout-terminal");
8189
+ });
8190
+ deps.broadcastSnapshotInvalidation(state);
8191
+ return deps.jsonResponse({
8192
+ ok: true,
8193
+ workspaceId: normalizeString(body.workspaceId) ?? RIG_WORKSPACE_ID,
8194
+ hostId,
8195
+ runId,
8196
+ leaseId,
8197
+ closeout: "server-owned",
8198
+ acceptedAt: new Date().toISOString()
8199
+ });
8200
+ }
7569
8201
  patchRunRecord(state.projectRoot, runId, {
7570
8202
  status: "completed",
7571
8203
  completedAt,
@@ -7708,6 +8340,14 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
7708
8340
  const page = await readRunLogsPage(state.projectRoot, runId, { limit, cursor });
7709
8341
  return deps.jsonResponse(page);
7710
8342
  }
8343
+ const runTimelineMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/timeline$/);
8344
+ if (runTimelineMatch) {
8345
+ const runId = decodeURIComponent(runTimelineMatch[1]);
8346
+ const limit = Number.parseInt(url.searchParams.get("limit") || "500", 10);
8347
+ const cursor = normalizeString(url.searchParams.get("cursor"));
8348
+ const page = await readRunTimelinePage(state.projectRoot, runId, { limit, cursor });
8349
+ return deps.jsonResponse(page);
8350
+ }
7711
8351
  const runSteerMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/steer$/);
7712
8352
  if (runSteerMatch && req.method === "POST") {
7713
8353
  const runId = decodeURIComponent(runSteerMatch[1]);
@@ -12924,6 +13564,7 @@ function buildHttpRouterDeps(state) {
12924
13564
  startLocalRun,
12925
13565
  stopRunRecord,
12926
13566
  resumeRunRecord,
13567
+ runServerOwnedPrCloseout,
12927
13568
  claimRemoteRun,
12928
13569
  listRemoteRunArtifacts,
12929
13570
  broadcastSnapshotInvalidation,