@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.
@@ -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;
@@ -2398,15 +2517,33 @@ say "Installing @h-rig/cli@latest"
2398
2517
  bun add -g @h-rig/cli@latest
2399
2518
 
2400
2519
  export BUN_INSTALL="\${BUN_INSTALL:-$HOME/.bun}"
2401
- export PATH="$BUN_INSTALL/bin:$PATH"
2520
+ BUN_RIG="$BUN_INSTALL/bin/rig"
2521
+ if [ ! -x "$BUN_RIG" ]; then
2522
+ printf 'rig-install: expected Bun global rig at %s but it was not executable.
2523
+ ' "$BUN_RIG" >&2
2524
+ exit 1
2525
+ fi
2526
+
2527
+ USER_BIN="$HOME/.local/bin"
2528
+ mkdir -p "$USER_BIN"
2529
+ cat > "$USER_BIN/rig" <<'RIG_SHIM'
2530
+ #!/usr/bin/env bash
2531
+ set -euo pipefail
2532
+ exec "\${BUN_INSTALL:-$HOME/.bun}/bin/rig" "$@"
2533
+ RIG_SHIM
2534
+ chmod +x "$USER_BIN/rig"
2535
+
2536
+ export PATH="$USER_BIN:$BUN_INSTALL/bin:$PATH"
2537
+ if command -v hash >/dev/null 2>&1; then hash -r; fi
2402
2538
 
2403
2539
  if ! command -v rig >/dev/null 2>&1; then
2404
- printf 'rig-install: rig installed, but rig is not on PATH. Add %s/bin to PATH and retry.
2405
- ' "$BUN_INSTALL" >&2
2540
+ printf 'rig-install: rig installed, but rig is not on PATH. Add %s and %s/bin to PATH and retry.
2541
+ ' "$USER_BIN" "$BUN_INSTALL" >&2
2406
2542
  exit 1
2407
2543
  fi
2408
2544
 
2409
2545
  say "Verifying rig"
2546
+ "$BUN_RIG" --help >/dev/null
2410
2547
  rig --help >/dev/null
2411
2548
  say "Done. Run: rig --help"
2412
2549
  `;
@@ -3145,16 +3282,12 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3145
3282
  if (!source) {
3146
3283
  return deps.badRequest("No task source is configured");
3147
3284
  }
3285
+ if (!source.updateTask && !(update.status && source.updateStatus)) {
3286
+ return deps.badRequest("Configured task source does not support updates");
3287
+ }
3148
3288
  const taskBeforeUpdate = source.get ? await source.get(id).catch(() => {
3149
3289
  return;
3150
3290
  }) : (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
3291
  const issueNodeId = normalizeString(body.issueNodeId) ?? extractGitHubIssueNodeId(taskBeforeUpdate);
3159
3292
  const projectSync = update.status ? await syncGitHubProjectStatusForTaskUpdate({
3160
3293
  taskId: id,
@@ -3163,6 +3296,35 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3163
3296
  token: createGitHubAuthStore(state.projectRoot).readToken(),
3164
3297
  config: ctx?.config
3165
3298
  }).catch((error) => ({ synced: false, reason: `error:${error instanceof Error ? error.message : String(error)}` })) : { synced: false, reason: "missing-status" };
3299
+ if (update.status && githubProjectsEnabled(ctx?.config) && projectSync.synced === false) {
3300
+ return deps.jsonResponse({ ok: false, id, projectSync, error: `GitHub Project status sync failed: ${String(projectSync.reason)}` }, 502);
3301
+ }
3302
+ try {
3303
+ if (source.updateTask) {
3304
+ await source.updateTask(id, update);
3305
+ } else if (update.status && source.updateStatus) {
3306
+ await source.updateStatus(id, update.status);
3307
+ }
3308
+ } catch (error) {
3309
+ let rollback = null;
3310
+ const previousStatus = normalizeString(taskBeforeUpdate?.status) ?? normalizeString(taskBeforeUpdate?.sourceStatus);
3311
+ if (update.status && previousStatus && githubProjectsEnabled(ctx?.config) && projectSync.synced !== false) {
3312
+ rollback = await syncGitHubProjectStatusForTaskUpdate({
3313
+ taskId: id,
3314
+ status: previousStatus,
3315
+ issueNodeId,
3316
+ token: createGitHubAuthStore(state.projectRoot).readToken(),
3317
+ config: ctx?.config
3318
+ }).catch((rollbackError) => ({ synced: false, reason: `rollback-error:${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}` }));
3319
+ }
3320
+ return deps.jsonResponse({
3321
+ ok: false,
3322
+ id,
3323
+ projectSync,
3324
+ rollback,
3325
+ error: `Task source update failed: ${error instanceof Error ? error.message : String(error)}`
3326
+ }, 502);
3327
+ }
3166
3328
  deps.snapshotService.invalidate("github-issue-updated");
3167
3329
  await state.taskProjectionReconciler?.tick("github-issue-updated").catch(() => {
3168
3330
  return;
@@ -3171,25 +3333,40 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
3171
3333
  return deps.jsonResponse({ ok: true, id, projectSync });
3172
3334
  }
3173
3335
  if (url.pathname === "/api/workspace/task-labels") {
3336
+ const ctx = await getCachedPluginHostContext(state.projectRoot).catch(() => null);
3337
+ if (url.searchParams.get("ensure") === "1" || req.method === "POST") {
3338
+ return deps.jsonResponse(await ensureGitHubLifecycleLabels(state.projectRoot, ctx?.config));
3339
+ }
3174
3340
  return deps.jsonResponse({
3175
3341
  ok: true,
3176
3342
  ready: true,
3177
3343
  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."
3344
+ labels: [...RIG_GITHUB_LIFECYCLE_LABELS],
3345
+ note: "Lifecycle labels are required during init; call POST /api/workspace/task-labels or ?ensure=1 to proactively create them."
3346
+ });
3347
+ }
3348
+ if (url.pathname === "/api/github/projects" && req.method === "GET") {
3349
+ const owner = normalizeString(url.searchParams.get("owner"));
3350
+ if (!owner)
3351
+ return deps.badRequest("owner is required");
3352
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
3353
+ if (!token)
3354
+ return deps.jsonResponse({ ok: false, error: "missing-token", projects: [] }, 401);
3355
+ const projects = await listGitHubProjects({ owner, token }).catch((error) => {
3356
+ throw new Error(error instanceof Error ? error.message : String(error));
3192
3357
  });
3358
+ return deps.jsonResponse({ ok: true, projects });
3359
+ }
3360
+ const projectStatusMatch = url.pathname.match(/^\/api\/github\/projects\/([^/]+)\/status-field$/);
3361
+ if (projectStatusMatch && req.method === "GET") {
3362
+ const projectId = decodeURIComponent(projectStatusMatch[1]);
3363
+ const token = createGitHubAuthStore(state.projectRoot).readToken();
3364
+ if (!token)
3365
+ return deps.jsonResponse({ ok: false, error: "missing-token" }, 401);
3366
+ const field = await resolveProjectStatusField({ projectId, token }).catch((error) => {
3367
+ throw new Error(error instanceof Error ? error.message : String(error));
3368
+ });
3369
+ return deps.jsonResponse({ ok: true, field });
3193
3370
  }
3194
3371
  if (url.pathname === "/api/workspace/issue-analysis/run" && req.method === "POST") {
3195
3372
  const body = await deps.readJsonBody(req);
@@ -4355,6 +4532,69 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
4355
4532
  }
4356
4533
  const run = leaseValidation.run;
4357
4534
  const completedAt = new Date().toISOString();
4535
+ const workspaceDir = normalizeString(body.workspaceDir) ?? normalizeString(body.runtimeWorkspace) ?? normalizeString(run.worktreePath);
4536
+ if (run.taskId && workspaceDir) {
4537
+ patchRunRecord(state.projectRoot, runId, {
4538
+ status: "reviewing",
4539
+ completedAt: null,
4540
+ hostId,
4541
+ endpointId: leaseId,
4542
+ worktreePath: workspaceDir,
4543
+ serverCloseout: {
4544
+ status: "pending",
4545
+ phase: "queued",
4546
+ requestedAt: completedAt,
4547
+ updatedAt: completedAt,
4548
+ runtimeWorkspace: workspaceDir,
4549
+ branch: normalizeString(body.branch) ?? normalizeString(run.branch) ?? `rig/${run.taskId}-${runId}`,
4550
+ taskId: run.taskId,
4551
+ source: "remote-complete"
4552
+ }
4553
+ });
4554
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
4555
+ id: `log:${runId}:remote-server-closeout-requested`,
4556
+ title: "Server-owned closeout requested",
4557
+ detail: "Remote run completed provider work and handed commit/PR/review/merge closeout to the Rig server.",
4558
+ tone: "info",
4559
+ status: "reviewing",
4560
+ createdAt: completedAt,
4561
+ payload: { workspaceDir, hostId, leaseId }
4562
+ }, "remote-server-closeout-requested");
4563
+ deps.runServerOwnedPrCloseout(state, runId).catch((error) => {
4564
+ const detail = error instanceof Error ? error.message : String(error);
4565
+ patchRunRecord(state.projectRoot, runId, {
4566
+ status: "failed",
4567
+ completedAt: new Date().toISOString(),
4568
+ errorText: detail,
4569
+ serverCloseout: {
4570
+ status: "failed",
4571
+ phase: "failed",
4572
+ updatedAt: new Date().toISOString(),
4573
+ error: detail
4574
+ }
4575
+ });
4576
+ deps.appendRunLogEntryAndBroadcast(state, runId, {
4577
+ id: `log:${runId}:remote-server-closeout-failed`,
4578
+ title: "Server-owned closeout failed",
4579
+ detail,
4580
+ tone: "error",
4581
+ status: "failed",
4582
+ createdAt: new Date().toISOString()
4583
+ }, "remote-server-closeout-failed");
4584
+ }).finally(() => {
4585
+ deps.reconcileScheduler(state, "remote-server-closeout-terminal");
4586
+ });
4587
+ deps.broadcastSnapshotInvalidation(state);
4588
+ return deps.jsonResponse({
4589
+ ok: true,
4590
+ workspaceId: normalizeString(body.workspaceId) ?? RIG_WORKSPACE_ID,
4591
+ hostId,
4592
+ runId,
4593
+ leaseId,
4594
+ closeout: "server-owned",
4595
+ acceptedAt: new Date().toISOString()
4596
+ });
4597
+ }
4358
4598
  patchRunRecord(state.projectRoot, runId, {
4359
4599
  status: "completed",
4360
4600
  completedAt,
@@ -4497,6 +4737,14 @@ data: ${JSON.stringify({ connectedAt: new Date().toISOString() })}
4497
4737
  const page = await readRunLogsPage(state.projectRoot, runId, { limit, cursor });
4498
4738
  return deps.jsonResponse(page);
4499
4739
  }
4740
+ const runTimelineMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/timeline$/);
4741
+ if (runTimelineMatch) {
4742
+ const runId = decodeURIComponent(runTimelineMatch[1]);
4743
+ const limit = Number.parseInt(url.searchParams.get("limit") || "500", 10);
4744
+ const cursor = normalizeString(url.searchParams.get("cursor"));
4745
+ const page = await readRunTimelinePage(state.projectRoot, runId, { limit, cursor });
4746
+ return deps.jsonResponse(page);
4747
+ }
4500
4748
  const runSteerMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/steer$/);
4501
4749
  if (runSteerMatch && req.method === "POST") {
4502
4750
  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,