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

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.
@@ -116,6 +116,7 @@ var DEFAULT_PROJECT_STATUSES = {
116
116
  running: "In Progress",
117
117
  prOpen: "In Review",
118
118
  ciFixing: "In Review",
119
+ merging: "Merging",
119
120
  done: "Done",
120
121
  needsAttention: "Needs Attention"
121
122
  };
@@ -129,6 +130,8 @@ function lifecycleStatusForTaskStatus(status) {
129
130
  return "prOpen";
130
131
  if (normalized === "ci_fixing" || normalized === "fixing")
131
132
  return "ciFixing";
133
+ if (normalized === "merging" || normalized === "merge")
134
+ return "merging";
132
135
  if (normalized === "failed" || normalized === "needs_attention" || normalized === "blocked")
133
136
  return "needsAttention";
134
137
  if (normalized === "in_progress" || normalized === "running" || normalized === "ready" || normalized === "open")
@@ -224,6 +224,18 @@ function readJsonlFileTail(path, options) {
224
224
  const completeLines = start > 0 ? lines.slice(1) : lines;
225
225
  return parseJsonlRecords(completeLines.filter(Boolean).slice(-limit));
226
226
  }
227
+ async function readRunTimelinePage(projectRoot, runId, options = {}) {
228
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
229
+ const cursor = options.cursor == null ? 0 : Number.parseInt(options.cursor, 10);
230
+ const entries = readJsonlFile(runTimelinePath(projectRoot, runId)).filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)));
231
+ const startInclusive = Number.isFinite(cursor) ? Math.max(0, Math.min(cursor, entries.length)) : 0;
232
+ const endExclusive = Math.min(entries.length, startInclusive + limit);
233
+ return {
234
+ entries: entries.slice(startInclusive, endExclusive).map((entry, offset) => ({ ...entry, cursor: startInclusive + offset + 1 })),
235
+ nextCursor: String(endExclusive),
236
+ hasMore: endExclusive < entries.length
237
+ };
238
+ }
227
239
  var INITIAL_RUN_LOG_TAIL_MAX_BYTES = 8 * 1024 * 1024;
228
240
  async function readRunLogsPage(projectRoot, runId, options = {}) {
229
241
  const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
@@ -788,6 +800,11 @@ import {
788
800
  buildTaskRunLifecycleComment,
789
801
  updateConfiguredTaskSourceTask
790
802
  } from "@rig/runtime/control-plane/tasks/source-lifecycle";
803
+ import {
804
+ closeIssueAfterMergedPr,
805
+ commitRunChanges,
806
+ runPrAutomation
807
+ } from "@rig/runtime/control-plane/native/pr-automation";
791
808
 
792
809
  // packages/server/src/scheduler.ts
793
810
  import { normalizeTaskLifecycleStatus } from "@rig/runtime/control-plane/state-sync/types";
@@ -1027,6 +1044,9 @@ function asRecord(value) {
1027
1044
  function asString(value) {
1028
1045
  return typeof value === "string" && value.trim().length > 0 ? value : undefined;
1029
1046
  }
1047
+ function asNumber(value) {
1048
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1049
+ }
1030
1050
  async function defaultGraphQLFetch(query, variables, token) {
1031
1051
  const response = await fetch("https://api.github.com/graphql", {
1032
1052
  method: "POST",
@@ -1043,6 +1063,32 @@ async function defaultGraphQLFetch(query, variables, token) {
1043
1063
  }
1044
1064
  return json.data;
1045
1065
  }
1066
+ function projectNodesFrom(data) {
1067
+ const root = asRecord(data);
1068
+ const owner = asRecord(root?.organization) ?? asRecord(root?.user);
1069
+ const projects = asRecord(owner?.projectsV2);
1070
+ const nodes = projects?.nodes;
1071
+ return Array.isArray(nodes) ? nodes : [];
1072
+ }
1073
+ async function listGitHubProjects(input) {
1074
+ const query = `
1075
+ query RigListProjects($owner: String!, $first: Int!) {
1076
+ organization(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
1077
+ user(login: $owner) { projectsV2(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { id number title url } } }
1078
+ }
1079
+ `;
1080
+ const fetchGraphQL = input.fetchGraphQL ?? defaultGraphQLFetch;
1081
+ const data = await fetchGraphQL(query, { owner: input.owner, first: input.first ?? 20 }, input.token);
1082
+ return projectNodesFrom(data).flatMap((node) => {
1083
+ const record = asRecord(node);
1084
+ const id = asString(record?.id);
1085
+ const number = asNumber(record?.number);
1086
+ const title = asString(record?.title);
1087
+ if (!id || number === undefined || !title)
1088
+ return [];
1089
+ return [{ id, number, title, ...asString(record?.url) ? { url: asString(record?.url) } : {} }];
1090
+ });
1091
+ }
1046
1092
  async function resolveProjectStatusField(input) {
1047
1093
  const query = `
1048
1094
  query RigProjectStatusField($projectId: ID!) {
@@ -1137,6 +1183,7 @@ var DEFAULT_PROJECT_STATUSES = {
1137
1183
  running: "In Progress",
1138
1184
  prOpen: "In Review",
1139
1185
  ciFixing: "In Review",
1186
+ merging: "Merging",
1140
1187
  done: "Done",
1141
1188
  needsAttention: "Needs Attention"
1142
1189
  };
@@ -1150,6 +1197,8 @@ function lifecycleStatusForTaskStatus(status) {
1150
1197
  return "prOpen";
1151
1198
  if (normalized === "ci_fixing" || normalized === "fixing")
1152
1199
  return "ciFixing";
1200
+ if (normalized === "merging" || normalized === "merge")
1201
+ return "merging";
1153
1202
  if (normalized === "failed" || normalized === "needs_attention" || normalized === "blocked")
1154
1203
  return "needsAttention";
1155
1204
  if (normalized === "in_progress" || normalized === "running" || normalized === "ready" || normalized === "open")
@@ -1225,7 +1274,8 @@ var TERMINAL_RUN_STATUSES2 = new Set([
1225
1274
  "needs-attention",
1226
1275
  "stopped"
1227
1276
  ]);
1228
- var RESUMABLE_LOCAL_RUN_STATUSES = new Set(["created", "preparing", "running", "validating", "reviewing"]);
1277
+ var RESUMABLE_SERVER_CLOSEOUT_STATUSES = new Set(["pending", "running"]);
1278
+ var ACTIVE_LOCAL_RUN_STATUSES = new Set(["created", "preparing", "running", "validating", "reviewing"]);
1229
1279
 
1230
1280
  // packages/server/src/server-helpers/ws-router.ts
1231
1281
  import {
@@ -2000,6 +2050,75 @@ function buildProjectConfigStatus(root) {
2000
2050
  suggestion: kind === "missing" ? "Run `rig init` in the project root to scaffold rig.config.ts." : null
2001
2051
  };
2002
2052
  }
2053
+ var RIG_GITHUB_LIFECYCLE_LABELS = [
2054
+ "ready",
2055
+ "blocked",
2056
+ "in-progress",
2057
+ "under-review",
2058
+ "failed",
2059
+ "cancelled",
2060
+ "rig:running",
2061
+ "rig:pr-open",
2062
+ "rig:ci-fixing",
2063
+ "rig:merging",
2064
+ "rig:done",
2065
+ "rig:needs-attention"
2066
+ ];
2067
+ function githubProjectsEnabled(config) {
2068
+ if (!config || typeof config !== "object" || Array.isArray(config))
2069
+ return false;
2070
+ const root = config;
2071
+ const github = root.github && typeof root.github === "object" && !Array.isArray(root.github) ? root.github : null;
2072
+ const projects = github?.projects && typeof github.projects === "object" && !Array.isArray(github.projects) ? github.projects : null;
2073
+ return projects?.enabled === true;
2074
+ }
2075
+ function githubIssueSourceRepo(config) {
2076
+ if (!config || typeof config !== "object" || Array.isArray(config))
2077
+ return null;
2078
+ const root = config;
2079
+ const taskSource = root.taskSource && typeof root.taskSource === "object" && !Array.isArray(root.taskSource) ? root.taskSource : null;
2080
+ const owner = normalizeString(taskSource?.owner);
2081
+ const repo = normalizeString(taskSource?.repo);
2082
+ if (taskSource?.kind === "github-issues" && owner && repo)
2083
+ return { owner, repo };
2084
+ const project = root.project && typeof root.project === "object" && !Array.isArray(root.project) ? root.project : null;
2085
+ const slug = normalizeString(project?.repo) ?? normalizeString(project?.name);
2086
+ const match = slug?.match(/^([^/]+)\/([^/]+)$/);
2087
+ return match ? { owner: match[1], repo: match[2] } : null;
2088
+ }
2089
+ async function ensureGitHubLifecycleLabels(projectRoot, config) {
2090
+ const repo = githubIssueSourceRepo(config);
2091
+ if (!repo)
2092
+ return { ok: false, ready: false, labelsReady: false, reason: "not-github-issues-source", labels: RIG_GITHUB_LIFECYCLE_LABELS };
2093
+ const token = createGitHubAuthStore(projectRoot).readToken();
2094
+ if (!token)
2095
+ return { ok: false, ready: false, labelsReady: false, reason: "missing-token", repo, labels: RIG_GITHUB_LIFECYCLE_LABELS };
2096
+ const existingResponse = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.repo}/labels?per_page=100`, {
2097
+ headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "user-agent": "rig-server" }
2098
+ });
2099
+ const existingJson = await existingResponse.json().catch(() => []);
2100
+ const existing = new Set(Array.isArray(existingJson) ? existingJson.flatMap((entry) => entry && typeof entry === "object" && typeof entry.name === "string" ? [entry.name] : []) : []);
2101
+ const created = [];
2102
+ const alreadyPresent = [];
2103
+ const failed = [];
2104
+ for (const label of RIG_GITHUB_LIFECYCLE_LABELS) {
2105
+ if (existing.has(label)) {
2106
+ alreadyPresent.push(label);
2107
+ continue;
2108
+ }
2109
+ const response = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.repo}/labels`, {
2110
+ method: "POST",
2111
+ headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "rig-server" },
2112
+ 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" })
2113
+ });
2114
+ if (response.ok || response.status === 422) {
2115
+ (response.status === 422 ? alreadyPresent : created).push(label);
2116
+ } else {
2117
+ failed.push({ label, error: await response.text().catch(() => response.statusText) });
2118
+ }
2119
+ }
2120
+ return { ok: failed.length === 0, ready: failed.length === 0, labelsReady: failed.length === 0, repo, labels: RIG_GITHUB_LIFECYCLE_LABELS, created, existing: alreadyPresent, failed };
2121
+ }
2003
2122
  function normalizeCommit(value) {
2004
2123
  const raw = normalizeString(value);
2005
2124
  return raw && /^[0-9a-f]{7,40}$/i.test(raw) ? raw : null;
@@ -3145,16 +3264,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3145
3264
  if (!source) {
3146
3265
  return deps.badRequest("No task source is configured");
3147
3266
  }
3267
+ if (!source.updateTask && !(update.status && source.updateStatus)) {
3268
+ return deps.badRequest("Configured task source does not support updates");
3269
+ }
3148
3270
  const taskBeforeUpdate = source.get ? await source.get(id).catch(() => {
3149
3271
  return;
3150
3272
  }) : (await deps.snapshotService.getWorkspaceTasks().catch(() => [])).find((task) => task.id === id);
3151
- if (source.updateTask) {
3152
- await source.updateTask(id, update);
3153
- } else if (update.status && source.updateStatus) {
3154
- await source.updateStatus(id, update.status);
3155
- } else {
3156
- return deps.badRequest("Configured task source does not support updates");
3157
- }
3158
3273
  const issueNodeId = normalizeString(body.issueNodeId) ?? extractGitHubIssueNodeId(taskBeforeUpdate);
3159
3274
  const projectSync = update.status ? await syncGitHubProjectStatusForTaskUpdate({
3160
3275
  taskId: id,
@@ -3163,6 +3278,35 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3163
3278
  token: createGitHubAuthStore(state.projectRoot).readToken(),
3164
3279
  config: ctx?.config
3165
3280
  }).catch((error) => ({ synced: false, reason: `error:${error instanceof Error ? error.message : String(error)}` })) : { synced: false, reason: "missing-status" };
3281
+ if (update.status && githubProjectsEnabled(ctx?.config) && projectSync.synced === false) {
3282
+ return deps.jsonResponse({ ok: false, id, projectSync, error: `GitHub Project status sync failed: ${String(projectSync.reason)}` }, 502);
3283
+ }
3284
+ try {
3285
+ if (source.updateTask) {
3286
+ await source.updateTask(id, update);
3287
+ } else if (update.status && source.updateStatus) {
3288
+ await source.updateStatus(id, update.status);
3289
+ }
3290
+ } catch (error) {
3291
+ let rollback = null;
3292
+ const previousStatus = normalizeString(taskBeforeUpdate?.status) ?? normalizeString(taskBeforeUpdate?.sourceStatus);
3293
+ if (update.status && previousStatus && githubProjectsEnabled(ctx?.config) && projectSync.synced !== false) {
3294
+ rollback = await syncGitHubProjectStatusForTaskUpdate({
3295
+ taskId: id,
3296
+ status: previousStatus,
3297
+ issueNodeId,
3298
+ token: createGitHubAuthStore(state.projectRoot).readToken(),
3299
+ config: ctx?.config
3300
+ }).catch((rollbackError) => ({ synced: false, reason: `rollback-error:${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}` }));
3301
+ }
3302
+ return deps.jsonResponse({
3303
+ ok: false,
3304
+ id,
3305
+ projectSync,
3306
+ rollback,
3307
+ error: `Task source update failed: ${error instanceof Error ? error.message : String(error)}`
3308
+ }, 502);
3309
+ }
3166
3310
  deps.snapshotService.invalidate("github-issue-updated");
3167
3311
  await state.taskProjectionReconciler?.tick("github-issue-updated").catch(() => {
3168
3312
  return;
@@ -3171,25 +3315,40 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3171
3315
  return deps.jsonResponse({ ok: true, id, projectSync });
3172
3316
  }
3173
3317
  if (url.pathname === "/api/workspace/task-labels") {
3318
+ const ctx = await getCachedPluginHostContext(state.projectRoot).catch(() => null);
3319
+ if (url.searchParams.get("ensure") === "1" || req.method === "POST") {
3320
+ return deps.jsonResponse(await ensureGitHubLifecycleLabels(state.projectRoot, ctx?.config));
3321
+ }
3174
3322
  return deps.jsonResponse({
3175
3323
  ok: true,
3176
3324
  ready: true,
3177
3325
  labelsReady: true,
3178
- labels: [
3179
- "ready",
3180
- "blocked",
3181
- "in-progress",
3182
- "under-review",
3183
- "failed",
3184
- "cancelled",
3185
- "rig:running",
3186
- "rig:pr-open",
3187
- "rig:ci-fixing",
3188
- "rig:done",
3189
- "rig:needs-attention"
3190
- ],
3191
- note: "GitHub issue lifecycle labels are created on demand by the configured task source when supported."
3326
+ labels: [...RIG_GITHUB_LIFECYCLE_LABELS],
3327
+ note: "Lifecycle labels are required during init; call POST /api/workspace/task-labels or ?ensure=1 to proactively create them."
3328
+ });
3329
+ }
3330
+ if (url.pathname === "/api/github/projects" && req.method === "GET") {
3331
+ const owner = normalizeString(url.searchParams.get("owner"));
3332
+ if (!owner)
3333
+ return deps.badRequest("owner is required");
3334
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
3335
+ if (!token)
3336
+ return deps.jsonResponse({ ok: false, error: "missing-token", projects: [] }, 401);
3337
+ const projects = await listGitHubProjects({ owner, token }).catch((error) => {
3338
+ throw new Error(error instanceof Error ? error.message : String(error));
3192
3339
  });
3340
+ return deps.jsonResponse({ ok: true, projects });
3341
+ }
3342
+ const projectStatusMatch = url.pathname.match(/^\/api\/github\/projects\/([^/]+)\/status-field$/);
3343
+ if (projectStatusMatch && req.method === "GET") {
3344
+ const projectId = decodeURIComponent(projectStatusMatch[1]);
3345
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
3346
+ if (!token)
3347
+ return deps.jsonResponse({ ok: false, error: "missing-token" }, 401);
3348
+ const field = await resolveProjectStatusField({ projectId, token }).catch((error) => {
3349
+ throw new Error(error instanceof Error ? error.message : String(error));
3350
+ });
3351
+ return deps.jsonResponse({ ok: true, field });
3193
3352
  }
3194
3353
  if (url.pathname === "/api/workspace/issue-analysis/run" && req.method === "POST") {
3195
3354
  const body = await deps.readJsonBody(req);
@@ -4355,6 +4514,69 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
4355
4514
  }
4356
4515
  const run = leaseValidation.run;
4357
4516
  const completedAt = new Date().toISOString();
4517
+ const workspaceDir = normalizeString(body.workspaceDir) ?? normalizeString(body.runtimeWorkspace) ?? normalizeString(run.worktreePath);
4518
+ if (run.taskId && workspaceDir) {
4519
+ patchRunRecord(state.projectRoot, runId, {
4520
+ status: "reviewing",
4521
+ completedAt: null,
4522
+ hostId,
4523
+ endpointId: leaseId,
4524
+ worktreePath: workspaceDir,
4525
+ serverCloseout: {
4526
+ status: "pending",
4527
+ phase: "queued",
4528
+ requestedAt: completedAt,
4529
+ updatedAt: completedAt,
4530
+ runtimeWorkspace: workspaceDir,
4531
+ branch: normalizeString(body.branch) ?? normalizeString(run.branch) ?? `rig/${run.taskId}-${runId}`,
4532
+ taskId: run.taskId,
4533
+ source: "remote-complete"
4534
+ }
4535
+ });
4536
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
4537
+ id: `log:${runId}:remote-server-closeout-requested`,
4538
+ title: "Server-owned closeout requested",
4539
+ detail: "Remote run completed provider work and handed commit/PR/review/merge closeout to the Rig server.",
4540
+ tone: "info",
4541
+ status: "reviewing",
4542
+ createdAt: completedAt,
4543
+ payload: { workspaceDir, hostId, leaseId }
4544
+ }, "remote-server-closeout-requested");
4545
+ deps.runServerOwnedPrCloseout(state, runId).catch((error) => {
4546
+ const detail = error instanceof Error ? error.message : String(error);
4547
+ patchRunRecord(state.projectRoot, runId, {
4548
+ status: "failed",
4549
+ completedAt: new Date().toISOString(),
4550
+ errorText: detail,
4551
+ serverCloseout: {
4552
+ status: "failed",
4553
+ phase: "failed",
4554
+ updatedAt: new Date().toISOString(),
4555
+ error: detail
4556
+ }
4557
+ });
4558
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
4559
+ id: `log:${runId}:remote-server-closeout-failed`,
4560
+ title: "Server-owned closeout failed",
4561
+ detail,
4562
+ tone: "error",
4563
+ status: "failed",
4564
+ createdAt: new Date().toISOString()
4565
+ }, "remote-server-closeout-failed");
4566
+ }).finally(() => {
4567
+ deps.reconcileScheduler(state, "remote-server-closeout-terminal");
4568
+ });
4569
+ deps.broadcastSnapshotInvalidation(state);
4570
+ return deps.jsonResponse({
4571
+ ok: true,
4572
+ workspaceId: normalizeString(body.workspaceId) ?? RIG_WORKSPACE_ID,
4573
+ hostId,
4574
+ runId,
4575
+ leaseId,
4576
+ closeout: "server-owned",
4577
+ acceptedAt: new Date().toISOString()
4578
+ });
4579
+ }
4358
4580
  patchRunRecord(state.projectRoot, runId, {
4359
4581
  status: "completed",
4360
4582
  completedAt,
@@ -4497,6 +4719,14 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
4497
4719
  const page = await readRunLogsPage(state.projectRoot, runId, { limit, cursor });
4498
4720
  return deps.jsonResponse(page);
4499
4721
  }
4722
+ const runTimelineMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/timeline$/);
4723
+ if (runTimelineMatch) {
4724
+ const runId = decodeURIComponent(runTimelineMatch[1]);
4725
+ const limit = Number.parseInt(url.searchParams.get("limit") || "500", 10);
4726
+ const cursor = normalizeString(url.searchParams.get("cursor"));
4727
+ const page = await readRunTimelinePage(state.projectRoot, runId, { limit, cursor });
4728
+ return deps.jsonResponse(page);
4729
+ }
4500
4730
  const runSteerMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/steer$/);
4501
4731
  if (runSteerMatch && req.method === "POST") {
4502
4732
  const runId = decodeURIComponent(runSteerMatch[1]);
@@ -167,6 +167,18 @@ function readJsonlFileTail(path, options) {
167
167
  function readRawRunLogs(projectRoot, runId) {
168
168
  return readJsonlFile(runLogsPath(projectRoot, runId)).filter((entry) => Boolean(entry && typeof entry === "object"));
169
169
  }
170
+ async function readRunTimelinePage(projectRoot, runId, options = {}) {
171
+ const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
172
+ const cursor = options.cursor == null ? 0 : Number.parseInt(options.cursor, 10);
173
+ const entries = readJsonlFile(runTimelinePath(projectRoot, runId)).filter((entry) => Boolean(entry && typeof entry === "object" && !Array.isArray(entry)));
174
+ const startInclusive = Number.isFinite(cursor) ? Math.max(0, Math.min(cursor, entries.length)) : 0;
175
+ const endExclusive = Math.min(entries.length, startInclusive + limit);
176
+ return {
177
+ entries: entries.slice(startInclusive, endExclusive).map((entry, offset) => ({ ...entry, cursor: startInclusive + offset + 1 })),
178
+ nextCursor: String(endExclusive),
179
+ hasMore: endExclusive < entries.length
180
+ };
181
+ }
170
182
  var INITIAL_RUN_LOG_TAIL_MAX_BYTES = 8 * 1024 * 1024;
171
183
  async function readRunLogsPage(projectRoot, runId, options = {}) {
172
184
  const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 200), 500));
@@ -252,6 +264,7 @@ export {
252
264
  remoteArtifactsRoot,
253
265
  readUserInputsForRuns,
254
266
  readUserInputs,
267
+ readRunTimelinePage,
255
268
  readRunLogsPage,
256
269
  readRunDetails,
257
270
  readRawRunLogs,