bosun 0.35.0 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui-server.mjs CHANGED
@@ -1796,6 +1796,7 @@ const SETTINGS_SENSITIVE_KEYS = new Set([
1796
1796
 
1797
1797
  const SETTINGS_KNOWN_SET = new Set(SETTINGS_KNOWN_KEYS);
1798
1798
  let _settingsLastUpdateTime = 0;
1799
+ const ASYNC_UI_COMMAND_BASES = new Set(["/plan"]);
1799
1800
 
1800
1801
  function hasSettingValue(value) {
1801
1802
  return value !== undefined && value !== null && value !== "";
@@ -2827,6 +2828,82 @@ function textResponse(res, statusCode, body, contentType = "text/plain") {
2827
2828
  res.end(body);
2828
2829
  }
2829
2830
 
2831
+ function normalizeTaskStatusKey(status) {
2832
+ return String(status || "")
2833
+ .trim()
2834
+ .toLowerCase()
2835
+ .replace(/[\s_-]+/g, "");
2836
+ }
2837
+
2838
+ function shouldRestartTaskAfterStatusUpdate(previousStatus, nextStatus) {
2839
+ const prev = normalizeTaskStatusKey(previousStatus);
2840
+ const next = normalizeTaskStatusKey(nextStatus);
2841
+ return prev === "inreview" && next === "inprogress";
2842
+ }
2843
+
2844
+ async function maybeRestartTaskOnReopen({
2845
+ taskId,
2846
+ previousStatus,
2847
+ nextStatus,
2848
+ updatedTask,
2849
+ adapter,
2850
+ executor,
2851
+ }) {
2852
+ if (!taskId) {
2853
+ return { attempted: false, started: false, reason: "missing_task_id" };
2854
+ }
2855
+ if (!shouldRestartTaskAfterStatusUpdate(previousStatus, nextStatus)) {
2856
+ return { attempted: false, started: false, reason: "transition_not_eligible" };
2857
+ }
2858
+ if (!executor) {
2859
+ return { attempted: true, started: false, reason: "executor_unavailable" };
2860
+ }
2861
+
2862
+ const status = executor.getStatus?.() || {};
2863
+ const activeSlots = Array.isArray(status?.slots) ? status.slots : [];
2864
+ const alreadyRunning = activeSlots.some(
2865
+ (slot) => String(slot?.taskId || "").trim() === String(taskId).trim(),
2866
+ );
2867
+ if (alreadyRunning) {
2868
+ return { attempted: true, started: false, reason: "already_running" };
2869
+ }
2870
+
2871
+ const maxParallel = Number(status?.maxParallel || 0);
2872
+ const activeCount = Number(status?.activeSlots || activeSlots.length || 0);
2873
+ const hasFreeSlot = maxParallel <= 0 || activeCount < maxParallel;
2874
+ if (!hasFreeSlot) {
2875
+ return { attempted: true, started: false, reason: "no_free_slots" };
2876
+ }
2877
+
2878
+ let taskToRun = updatedTask && typeof updatedTask === "object" ? updatedTask : null;
2879
+ if (!taskToRun?.id && typeof adapter?.getTask === "function") {
2880
+ try {
2881
+ taskToRun = await adapter.getTask(taskId);
2882
+ } catch (err) {
2883
+ return {
2884
+ attempted: true,
2885
+ started: false,
2886
+ reason: "task_lookup_failed",
2887
+ error: err?.message || String(err),
2888
+ };
2889
+ }
2890
+ }
2891
+ if (!taskToRun) {
2892
+ return { attempted: true, started: false, reason: "task_not_found" };
2893
+ }
2894
+
2895
+ executor.executeTask(taskToRun, {
2896
+ force: true,
2897
+ recoveredFromInProgress: true,
2898
+ }).catch((error) => {
2899
+ console.warn(
2900
+ `[telegram-ui] failed to restart reopened task ${taskId}: ${error.message}`,
2901
+ );
2902
+ });
2903
+
2904
+ return { attempted: true, started: true, reason: "restarted" };
2905
+ }
2906
+
2830
2907
  function parseInitData(initData) {
2831
2908
  const params = new URLSearchParams(initData);
2832
2909
  const data = {};
@@ -4609,6 +4686,9 @@ async function handleApi(req, res, url) {
4609
4686
  return;
4610
4687
  }
4611
4688
  const adapter = getKanbanAdapter();
4689
+ const previousTask = typeof adapter.getTask === "function"
4690
+ ? await adapter.getTask(taskId).catch(() => null)
4691
+ : null;
4612
4692
  const tagsProvided = body && Object.prototype.hasOwnProperty.call(body, "tags");
4613
4693
  const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
4614
4694
  const draftProvided = body && Object.prototype.hasOwnProperty.call(body, "draft");
@@ -4651,12 +4731,27 @@ async function handleApi(req, res, url) {
4651
4731
  typeof adapter.updateTask === "function"
4652
4732
  ? await adapter.updateTask(taskId, patch)
4653
4733
  : await adapter.updateTaskStatus(taskId, patch.status);
4654
- jsonResponse(res, 200, { ok: true, data: updated });
4734
+ const executor = uiDeps.getInternalExecutor?.() || null;
4735
+ const restart = await maybeRestartTaskOnReopen({
4736
+ taskId,
4737
+ previousStatus: previousTask?.status || null,
4738
+ nextStatus: updated?.status || patch.status || null,
4739
+ updatedTask: updated,
4740
+ adapter,
4741
+ executor,
4742
+ });
4743
+ jsonResponse(res, 200, { ok: true, data: updated, restart });
4655
4744
  broadcastUiEvent(["tasks", "overview"], "invalidate", {
4656
4745
  reason: "task-updated",
4657
4746
  taskId,
4658
4747
  status: updated?.status || patch.status || null,
4659
4748
  });
4749
+ if (restart?.started) {
4750
+ broadcastUiEvent(["tasks", "overview", "executor", "agents"], "invalidate", {
4751
+ reason: "task-restarted",
4752
+ taskId,
4753
+ });
4754
+ }
4660
4755
  } catch (err) {
4661
4756
  jsonResponse(res, 500, { ok: false, error: err.message });
4662
4757
  }
@@ -4672,6 +4767,9 @@ async function handleApi(req, res, url) {
4672
4767
  return;
4673
4768
  }
4674
4769
  const adapter = getKanbanAdapter();
4770
+ const previousTask = typeof adapter.getTask === "function"
4771
+ ? await adapter.getTask(taskId).catch(() => null)
4772
+ : null;
4675
4773
  const tagsProvided = body && Object.prototype.hasOwnProperty.call(body, "tags");
4676
4774
  const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
4677
4775
  const draftProvided = body && Object.prototype.hasOwnProperty.call(body, "draft");
@@ -4714,12 +4812,27 @@ async function handleApi(req, res, url) {
4714
4812
  typeof adapter.updateTask === "function"
4715
4813
  ? await adapter.updateTask(taskId, patch)
4716
4814
  : await adapter.updateTaskStatus(taskId, patch.status);
4717
- jsonResponse(res, 200, { ok: true, data: updated });
4815
+ const executor = uiDeps.getInternalExecutor?.() || null;
4816
+ const restart = await maybeRestartTaskOnReopen({
4817
+ taskId,
4818
+ previousStatus: previousTask?.status || null,
4819
+ nextStatus: updated?.status || patch.status || null,
4820
+ updatedTask: updated,
4821
+ adapter,
4822
+ executor,
4823
+ });
4824
+ jsonResponse(res, 200, { ok: true, data: updated, restart });
4718
4825
  broadcastUiEvent(["tasks", "overview"], "invalidate", {
4719
4826
  reason: "task-edited",
4720
4827
  taskId,
4721
4828
  status: updated?.status || patch.status || null,
4722
4829
  });
4830
+ if (restart?.started) {
4831
+ broadcastUiEvent(["tasks", "overview", "executor", "agents"], "invalidate", {
4832
+ reason: "task-restarted",
4833
+ taskId,
4834
+ });
4835
+ }
4723
4836
  } catch (err) {
4724
4837
  jsonResponse(res, 500, { ok: false, error: err.message });
4725
4838
  }
@@ -6620,8 +6733,44 @@ async function handleApi(req, res, url) {
6620
6733
  }
6621
6734
  const handler = uiDeps.handleUiCommand;
6622
6735
  if (typeof handler === "function") {
6623
- const result = await handler(command);
6624
- jsonResponse(res, 200, { ok: true, data: result || null, command });
6736
+ if (ASYNC_UI_COMMAND_BASES.has(cmdBase)) {
6737
+ setImmediate(() => {
6738
+ let pending;
6739
+ try {
6740
+ pending = Promise.resolve(handler(command));
6741
+ } catch (err) {
6742
+ console.warn(`[ui] async command failed (${command}): ${err?.message || err}`);
6743
+ broadcastUiEvent(["overview", "executor", "tasks"], "invalidate", {
6744
+ reason: "command-failed",
6745
+ command,
6746
+ });
6747
+ return;
6748
+ }
6749
+ pending
6750
+ .then(() => {
6751
+ broadcastUiEvent(["overview", "executor", "tasks"], "invalidate", {
6752
+ reason: "command-executed",
6753
+ command,
6754
+ });
6755
+ })
6756
+ .catch((err) => {
6757
+ console.warn(`[ui] async command failed (${command}): ${err?.message || err}`);
6758
+ broadcastUiEvent(["overview", "executor", "tasks"], "invalidate", {
6759
+ reason: "command-failed",
6760
+ command,
6761
+ });
6762
+ });
6763
+ });
6764
+ jsonResponse(res, 202, {
6765
+ ok: true,
6766
+ queued: true,
6767
+ command,
6768
+ message: "Command accepted and running in background.",
6769
+ });
6770
+ } else {
6771
+ const result = await handler(command);
6772
+ jsonResponse(res, 200, { ok: true, data: result || null, command });
6773
+ }
6625
6774
  } else {
6626
6775
  // No command handler wired — acknowledge and broadcast refresh
6627
6776
  jsonResponse(res, 200, {
@@ -6632,7 +6781,7 @@ async function handleApi(req, res, url) {
6632
6781
  });
6633
6782
  }
6634
6783
  broadcastUiEvent(["overview", "executor", "tasks"], "invalidate", {
6635
- reason: "command-executed",
6784
+ reason: ASYNC_UI_COMMAND_BASES.has(cmdBase) ? "command-queued" : "command-executed",
6636
6785
  command,
6637
6786
  });
6638
6787
  } catch (err) {
@@ -7326,7 +7475,8 @@ async function handleStatic(req, res, url) {
7326
7475
  return;
7327
7476
  }
7328
7477
 
7329
- const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
7478
+ const rawPathname = String(url.pathname || "/").trim() || "/";
7479
+ const pathname = rawPathname === "/" ? "/index.html" : rawPathname;
7330
7480
  const filePath = resolve(uiRoot, `.${pathname}`);
7331
7481
 
7332
7482
  if (!filePath.startsWith(uiRoot)) {
@@ -7335,6 +7485,28 @@ async function handleStatic(req, res, url) {
7335
7485
  }
7336
7486
 
7337
7487
  if (!existsSync(filePath)) {
7488
+ // SPA fallback: deep links like /tasks/123 must load index.html so the
7489
+ // client router can resolve the route after refresh.
7490
+ const looksLikeFile = /\.[a-z0-9]+$/i.test(pathname);
7491
+ const hasTemplateBraces = pathname.includes("{{") || pathname.includes("}}");
7492
+ if (!looksLikeFile || hasTemplateBraces) {
7493
+ const indexPath = resolve(uiRoot, "index.html");
7494
+ if (existsSync(indexPath)) {
7495
+ try {
7496
+ const data = await readFile(indexPath);
7497
+ res.writeHead(200, {
7498
+ "Content-Type": "text/html; charset=utf-8",
7499
+ "Access-Control-Allow-Origin": "*",
7500
+ "Cache-Control": "no-store",
7501
+ });
7502
+ res.end(data);
7503
+ return;
7504
+ } catch (err) {
7505
+ textResponse(res, 500, `Failed to load /index.html: ${err.message}`);
7506
+ return;
7507
+ }
7508
+ }
7509
+ }
7338
7510
  textResponse(res, 404, "Not Found");
7339
7511
  return;
7340
7512
  }