@co0ontty/wand 4.75.2 → 4.76.0-beta.ge98b32f

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "f59a3c6dc6450e8289c06a75742258844046afd0",
3
- "builtAt": "2026-09-24T07:36:52.533Z",
4
- "version": "4.75.2",
5
- "channel": "stable"
2
+ "commit": "e98b32fc784c024dd38d1996355044224f2bf398",
3
+ "builtAt": "2026-09-25T01:10:41.447Z",
4
+ "version": "4.76.0-beta.ge98b32f",
5
+ "channel": "beta"
6
6
  }
@@ -45,6 +45,8 @@ function workspaceSessionSummary(session, names = {}, registry) {
45
45
  function cheapTasksRevision(storage, registry) {
46
46
  const fingerprint = [
47
47
  storage.tasksAggregateFingerprint(),
48
+ // 排序只更新偏好,不改变任务和会话;轮询 revision 也必须跟着变化。
49
+ storage.getWorkspaceGroupOrder(),
48
50
  ...(registry?.listSlim() ?? []).map((session) => [
49
51
  session.id,
50
52
  session.status,
@@ -58,6 +60,38 @@ function cheapTasksRevision(storage, registry) {
58
60
  ];
59
61
  return crypto.createHash("sha256").update(JSON.stringify(fingerprint)).digest("base64url");
60
62
  }
63
+ /**
64
+ * 目录组排序:先按用户拖动保存的顺序,再按默认规则(全局组在前、创建时间倒序、id 兜底)。
65
+ *
66
+ * 用户只要拖过一次,顺序就完全按存储值走——包括把「未归属」挪到后面;
67
+ * 没拖过(存储为空)时保持原有默认观感,不让改动影响没动过排序的人。
68
+ */
69
+ function sortTaskDirectoryGroups(groups, order) {
70
+ const position = new Map();
71
+ order.forEach((id, index) => position.set(id, index));
72
+ const defaultSorted = [...groups].sort((left, right) => {
73
+ const global = Number(Boolean(right.global)) - Number(Boolean(left.global));
74
+ if (global)
75
+ return global;
76
+ const created = compareCreatedDesc(left.createdAt, right.createdAt);
77
+ if (created)
78
+ return created;
79
+ return String(left.workspaceId).localeCompare(String(right.workspaceId));
80
+ });
81
+ if (position.size === 0)
82
+ return defaultSorted;
83
+ return defaultSorted.sort((left, right) => {
84
+ const leftIndex = position.get(left.workspaceId);
85
+ const rightIndex = position.get(right.workspaceId);
86
+ if (leftIndex === undefined && rightIndex === undefined)
87
+ return 0;
88
+ if (leftIndex === undefined)
89
+ return 1;
90
+ if (rightIndex === undefined)
91
+ return -1;
92
+ return leftIndex - rightIndex;
93
+ });
94
+ }
61
95
  function compareCreatedDesc(left, right) {
62
96
  const a = typeof left === "string" ? left.trim() : "";
63
97
  const b = typeof right === "string" ? right.trim() : "";
@@ -619,23 +653,38 @@ export function registerWorkspaceRoutes(app, storage, sessions, titleOptions = {
619
653
  if (group.synthetic)
620
654
  rememberCreatedAt(group, session.startedAt);
621
655
  }
622
- const payload = [...groups.values()]
623
- .filter((group) => !group.global || group.tasks.length > 0 || group.standaloneSessions.length > 0)
624
- .sort((left, right) => {
625
- const global = Number(Boolean(right.global)) - Number(Boolean(left.global));
626
- if (global)
627
- return global;
628
- const created = compareCreatedDesc(left.createdAt, right.createdAt);
629
- if (created)
630
- return created;
631
- return String(left.workspaceId).localeCompare(String(right.workspaceId));
632
- });
656
+ const orderedGroups = [...groups.values()]
657
+ .filter((group) => !group.global || group.tasks.length > 0 || group.standaloneSessions.length > 0);
658
+ const payload = sortTaskDirectoryGroups(orderedGroups, storage.getWorkspaceGroupOrder());
633
659
  if (hasRevisionQuery) {
634
660
  res.json({ unchanged: false, revision: cheapTasksRevision(storage, sessions), groups: payload });
635
661
  return;
636
662
  }
637
663
  res.json(payload);
638
664
  });
665
+ /**
666
+ * PUT /api/workspaces/order —— 保存首页目录组的展示顺序(拖动排序)。
667
+ *
668
+ * 存的是客户端看到的组 id(真实工作区 workspace id,合成目录 `cwd:<规范化路径>`),
669
+ * 未出现在本次提交里的既有 id 会按原相对顺序接在后面,避免某个客户端
670
+ * 只知道自己那部分分组时把别人的顺序抹掉。
671
+ */
672
+ app.put("/api/workspaces/order", (req, res) => {
673
+ const body = req.body;
674
+ const incoming = Array.isArray(body?.ids)
675
+ ? body.ids.filter((value) => typeof value === "string").slice(0, 500)
676
+ : null;
677
+ if (!incoming) {
678
+ res.status(400).json({ error: "缺少 ids 列表。" });
679
+ return;
680
+ }
681
+ const submitted = [...new Set(incoming.map((id) => id.trim()).filter(Boolean))];
682
+ const submittedSet = new Set(submitted);
683
+ const kept = storage.getWorkspaceGroupOrder().filter((id) => !submittedSet.has(id));
684
+ const next = [...submitted, ...kept];
685
+ storage.setWorkspaceGroupOrder(next);
686
+ res.json({ ok: true, ids: next });
687
+ });
639
688
  // 列出某工作空间下的任务
640
689
  app.get("/api/workspaces/:id/tasks", (req, res) => {
641
690
  const workspace = storage.getWorkspace(req.params.id);
package/dist/storage.d.ts CHANGED
@@ -49,6 +49,15 @@ export declare class WandStorage {
49
49
  setPreference<T>(key: string, value: T | null | undefined): void;
50
50
  /** 判断偏好是否在 DB 中存在(区别于值为 null/false/"")。 */
51
51
  hasPreference(key: string): boolean;
52
+ /**
53
+ * 用户在首页拖动排序后的目录组顺序(服务端偏好,所有客户端共用)。
54
+ * 存的是客户端看到的组 id:真实工作区用 workspace id,合成目录用 `cwd:<规范化路径>`。
55
+ * 不存在的 id 只是被忽略,不影响渲染。
56
+ */
57
+ getWorkspaceGroupOrder(): string[];
58
+ setWorkspaceGroupOrder(ids: string[]): void;
59
+ /** 工作区被删除后,把它的排序位也清掉,避免 id 被复用后继承旧位置。 */
60
+ forgetWorkspaceGroupOrder(id: string): void;
52
61
  /** Return user-defined workspace labels keyed by normalized session cwd. */
53
62
  listSessionDirectoryNames(): Map<string, string>;
54
63
  /** Set a workspace label, or remove it when name is null/blank. */
package/dist/storage.js CHANGED
@@ -1011,6 +1011,49 @@ export class WandStorage {
1011
1011
  hasPreference(key) {
1012
1012
  return this.getConfigValue(key) !== null;
1013
1013
  }
1014
+ // ============ 首页目录组顺序 ============
1015
+ /**
1016
+ * 用户在首页拖动排序后的目录组顺序(服务端偏好,所有客户端共用)。
1017
+ * 存的是客户端看到的组 id:真实工作区用 workspace id,合成目录用 `cwd:<规范化路径>`。
1018
+ * 不存在的 id 只是被忽略,不影响渲染。
1019
+ */
1020
+ getWorkspaceGroupOrder() {
1021
+ const stored = this.getPreference(WORKSPACE_GROUP_ORDER_KEY, []);
1022
+ if (!Array.isArray(stored))
1023
+ return [];
1024
+ const seen = new Set();
1025
+ const ids = [];
1026
+ for (const value of stored) {
1027
+ if (typeof value !== "string")
1028
+ continue;
1029
+ const id = value.trim();
1030
+ if (!id || seen.has(id))
1031
+ continue;
1032
+ seen.add(id);
1033
+ ids.push(id);
1034
+ }
1035
+ return ids;
1036
+ }
1037
+ setWorkspaceGroupOrder(ids) {
1038
+ const seen = new Set();
1039
+ const cleaned = [];
1040
+ for (const value of ids) {
1041
+ const id = typeof value === "string" ? value.trim() : "";
1042
+ if (!id || seen.has(id))
1043
+ continue;
1044
+ seen.add(id);
1045
+ cleaned.push(id);
1046
+ }
1047
+ // 空顺序等于「没设置过」,回到默认排序,而不是存一个空数组。
1048
+ this.setPreference(WORKSPACE_GROUP_ORDER_KEY, cleaned.length > 0 ? cleaned : null);
1049
+ }
1050
+ /** 工作区被删除后,把它的排序位也清掉,避免 id 被复用后继承旧位置。 */
1051
+ forgetWorkspaceGroupOrder(id) {
1052
+ const current = this.getWorkspaceGroupOrder();
1053
+ if (!current.includes(id))
1054
+ return;
1055
+ this.setWorkspaceGroupOrder(current.filter((item) => item !== id));
1056
+ }
1014
1057
  // ============ Session Directory Names ============
1015
1058
  /** Return user-defined workspace labels keyed by normalized session cwd. */
1016
1059
  listSessionDirectoryNames() {
@@ -1124,6 +1167,7 @@ export class WandStorage {
1124
1167
  OR workspace_task_id IN (SELECT id FROM workspace_tasks WHERE workspace_id = ?)`).run(id, id);
1125
1168
  }
1126
1169
  this.db.prepare("DELETE FROM workspaces WHERE id = ?").run(id);
1170
+ this.forgetWorkspaceGroupOrder(id);
1127
1171
  }
1128
1172
  listSessionsByWorkspace(workspaceId) {
1129
1173
  const rows = this.db
@@ -2151,6 +2195,8 @@ const SCHEMA_MIGRATIONS = [
2151
2195
  ["workspace_id", "ALTER TABLE command_sessions ADD COLUMN workspace_id TEXT"],
2152
2196
  ["workspace_task_id", "ALTER TABLE command_sessions ADD COLUMN workspace_task_id TEXT"],
2153
2197
  ];
2198
+ /** 首页目录组顺序的偏好键(app_config 表,与其它 UI 偏好同一套读写)。 */
2199
+ const WORKSPACE_GROUP_ORDER_KEY = "workspaceGroupOrder";
2154
2200
  const AUTH_SESSION_MIGRATIONS = [
2155
2201
  ["kind", "ALTER TABLE auth_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'browser-admin'"],
2156
2202
  ["scopes", `ALTER TABLE auth_sessions ADD COLUMN scopes TEXT NOT NULL DEFAULT '["admin"]'`],
@@ -11,4 +11,4 @@ export interface StructuredRunRetentionEntry {
11
11
  */
12
12
  export declare function structuredRunEvictionOrder(entries: readonly StructuredRunRetentionEntry[], now?: number): string[];
13
13
  export declare function runTerminalDaemon(configPath: string): Promise<void>;
14
- export declare function readTerminalDaemonPid(configPath: string): number | null;
14
+ export declare function readTerminalDaemonPid(configPath: string, uidOverride?: number): number | null;
@@ -531,9 +531,9 @@ function fileHoldsValue(filePath, expected) {
531
531
  return false;
532
532
  }
533
533
  }
534
- export function readTerminalDaemonPid(configPath) {
534
+ export function readTerminalDaemonPid(configPath, uidOverride) {
535
535
  try {
536
- const value = Number(readFileSync(terminalDaemonPaths(configPath).pidPath, "utf8").trim());
536
+ const value = Number(readFileSync(terminalDaemonPaths(configPath, uidOverride).pidPath, "utf8").trim());
537
537
  return Number.isSafeInteger(value) && value > 0 ? value : null;
538
538
  }
539
539
  catch {
@@ -643,8 +643,23 @@ function terminalServicePathFor(scope) {
643
643
  }
644
644
  return "";
645
645
  }
646
+ /**
647
+ * 本 config 的 config 文件 owner uid。
648
+ *
649
+ * daemon 在 unit/plist 里被钉成 config owner,端点路径里的 uid 也是它;而
650
+ * `wand service:install` 常常是 sudo 跑的(uid 0),所以任何涉及 daemon 文件的读取
651
+ * 都必须用这个 uid,不能用调用者的。
652
+ */
653
+ function configOwnerUid(configPath) {
654
+ try {
655
+ return statUid(path.resolve(configPath));
656
+ }
657
+ catch {
658
+ return undefined;
659
+ }
660
+ }
646
661
  function hasLiveTerminalDaemon(configPath) {
647
- const pid = readTerminalDaemonPid(configPath);
662
+ const pid = readTerminalDaemonPid(configPath, configOwnerUid(configPath));
648
663
  return pid !== null && isPidAlive(pid);
649
664
  }
650
665
  /** 按 config 派生两套 daemon 的端点(socket 在 /tmp、pid 在 config 目录旁边)。
@@ -654,14 +669,7 @@ function hasLiveTerminalDaemon(configPath) {
654
669
  * 端点,把健康 daemon 当成僵尸(2026-09-24 真实发生过,两个 daemon 被误杀)。
655
670
  */
656
671
  function daemonEndpointStates(configPath) {
657
- const ownerUid = (() => {
658
- try {
659
- return statUid(path.resolve(configPath));
660
- }
661
- catch {
662
- return undefined;
663
- }
664
- })();
672
+ const ownerUid = configOwnerUid(configPath);
665
673
  const render = renderPaths(configPath, ownerUid);
666
674
  const terminal = terminalDaemonPaths(configPath, ownerUid);
667
675
  const endpoints = [
@@ -743,7 +751,7 @@ export function isDaemonProcessForConfig(pid, configPath, kind) {
743
751
  * 看不到 detail,所以这句拼进 message 里。
744
752
  */
745
753
  function staleDaemonNote(configPath) {
746
- const render = renderPaths(configPath);
754
+ const render = renderPaths(configPath, configOwnerUid(configPath));
747
755
  const pid = readDaemonPidFile(render.pidPath);
748
756
  if (pid === null || !isPidAlive(pid))
749
757
  return null;