@vellumai/assistant 0.9.0-dev.202606180940.f141ea3 → 0.9.0-dev.202606181324.0804a69

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.
@@ -49,8 +49,7 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
49
49
 
50
50
  /**
51
51
  * Hard-exempt internal channels — never subject to PUT policy, omitted from
52
- * GET list, runtime admission stage short-circuits without floor check,
53
- * gateway kill switch skips the `no_one` check.
52
+ * GET list, runtime admission stage short-circuits without floor check.
54
53
  *
55
54
  * `platform` / `a2a` are peer/internal channels with no human-trust model.
56
55
  *
@@ -61,8 +60,8 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
61
60
  * until voice ingress is wired in a follow-up PR. Remove `"phone"` from this
62
61
  * set once the voice path enforces admission.
63
62
  *
64
- * `vellum` is intentionally NOT exempt — it is client-configurable, with the
65
- * single restriction in {@link KILL_SWITCH_FORBIDDEN_CHANNELS}.
63
+ * `vellum` is NOT exempt — its floor is still enforced at runtime — but it is
64
+ * hidden from the configurable UI; see {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
66
65
  */
67
66
  export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
68
67
  "platform",
@@ -75,18 +74,24 @@ export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
75
74
  }
76
75
 
77
76
  /**
78
- * Channels that are configurable but may never be set to `no_one` (the kill
79
- * switch). `vellum` is the local desktop/web client channel; allowing `no_one`
80
- * there would let the guardian lock themselves out of their own client. The
81
- * guardian is always classified `guardian` (max rank) on vellum, so every
82
- * floor except `no_one` still admits them — only the kill switch is forbidden.
77
+ * Channels omitted from the Channel Trust Floors list (GET) and rejected on
78
+ * PUT/DELETE — managed automatically at their seed default, not user
79
+ * configurable. Unlike {@link ADMISSION_POLICY_EXEMPT_CHANNELS} they are still
80
+ * enforced at runtime, so hiding a real inbound channel like `whatsapp` never
81
+ * silently disables its admission floor check. The startup seed re-pins any
82
+ * drifted row so a stale floor (e.g. a legacy `no_one`) can't strand a channel
83
+ * the user can no longer see.
84
+ *
85
+ * `vellum` is the local desktop/web client surface; the guardian is always
86
+ * max-rank there, so the seed default admits them regardless of the floor.
83
87
  */
84
- export const KILL_SWITCH_FORBIDDEN_CHANNELS: ReadonlySet<string> = new Set([
88
+ export const ADMISSION_POLICY_HIDDEN_CHANNELS: ReadonlySet<string> = new Set([
85
89
  "vellum",
90
+ "whatsapp",
86
91
  ]);
87
92
 
88
- export function isKillSwitchForbiddenChannel(channelType: string): boolean {
89
- return KILL_SWITCH_FORBIDDEN_CHANNELS.has(channelType);
93
+ export function isAdmissionPolicyHiddenChannel(channelType: string): boolean {
94
+ return ADMISSION_POLICY_HIDDEN_CHANNELS.has(channelType);
90
95
  }
91
96
 
92
97
  export function isAdmissionPolicy(value: unknown): value is AdmissionPolicy {
@@ -56,17 +56,17 @@ export type { IpcRequest, IpcResponse, Logger } from "./types.js";
56
56
 
57
57
  export { noopLogger } from "./types.js";
58
58
 
59
- // Admission policy contract (gateway → daemon) — Zod schemas + derived types + exempt channels
59
+ // Admission policy contract (gateway → daemon) — Zod schemas + derived types + channel sets
60
60
  export {
61
61
  ADMISSION_FLOOR,
62
62
  ADMISSION_POLICY_DEFAULT,
63
63
  ADMISSION_POLICY_EXEMPT_CHANNELS,
64
+ ADMISSION_POLICY_HIDDEN_CHANNELS,
64
65
  ADMISSION_POLICY_VALUES,
65
66
  AdmissionPolicySchema,
66
67
  isAdmissionPolicy,
67
68
  isAdmissionPolicyExemptChannel,
68
- isKillSwitchForbiddenChannel,
69
- KILL_SWITCH_FORBIDDEN_CHANNELS,
69
+ isAdmissionPolicyHiddenChannel,
70
70
  } from "./admission-policy-contract.js";
71
71
 
72
72
  export type { AdmissionPolicy } from "./admission-policy-contract.js";
package/openapi.yaml CHANGED
@@ -12116,6 +12116,19 @@ paths:
12116
12116
  propertyNames:
12117
12117
  type: string
12118
12118
  additionalProperties: {}
12119
+ sourceType:
12120
+ type: string
12121
+ enum:
12122
+ - heartbeat
12123
+ - memory_consolidation
12124
+ - schedule
12125
+ - auto_analysis
12126
+ - user
12127
+ - other
12128
+ sourceKey:
12129
+ type: string
12130
+ sourceLabel:
12131
+ type: string
12119
12132
  createdAt:
12120
12133
  type: string
12121
12134
  required:
@@ -12296,6 +12309,19 @@ paths:
12296
12309
  propertyNames:
12297
12310
  type: string
12298
12311
  additionalProperties: {}
12312
+ sourceType:
12313
+ type: string
12314
+ enum:
12315
+ - heartbeat
12316
+ - memory_consolidation
12317
+ - schedule
12318
+ - auto_analysis
12319
+ - user
12320
+ - other
12321
+ sourceKey:
12322
+ type: string
12323
+ sourceLabel:
12324
+ type: string
12299
12325
  createdAt:
12300
12326
  type: string
12301
12327
  required:
@@ -12508,6 +12534,19 @@ paths:
12508
12534
  propertyNames:
12509
12535
  type: string
12510
12536
  additionalProperties: {}
12537
+ sourceType:
12538
+ type: string
12539
+ enum:
12540
+ - heartbeat
12541
+ - memory_consolidation
12542
+ - schedule
12543
+ - auto_analysis
12544
+ - user
12545
+ - other
12546
+ sourceKey:
12547
+ type: string
12548
+ sourceLabel:
12549
+ type: string
12511
12550
  createdAt:
12512
12551
  type: string
12513
12552
  required:
@@ -17936,6 +17975,19 @@ paths:
17936
17975
  propertyNames:
17937
17976
  type: string
17938
17977
  additionalProperties: {}
17978
+ sourceType:
17979
+ type: string
17980
+ enum:
17981
+ - heartbeat
17982
+ - memory_consolidation
17983
+ - schedule
17984
+ - auto_analysis
17985
+ - user
17986
+ - other
17987
+ sourceKey:
17988
+ type: string
17989
+ sourceLabel:
17990
+ type: string
17939
17991
  createdAt:
17940
17992
  type: string
17941
17993
  required:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606180940.f141ea3",
3
+ "version": "0.9.0-dev.202606181324.0804a69",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,6 +6,8 @@ const GIB = 1024 * MIB;
6
6
  let existingPaths = new Set<string>();
7
7
  const workspaceDir = "/workspace";
8
8
  let minikubeStorageSize: string | undefined;
9
+ let isContainerized = false;
10
+ let isPlatform = false;
9
11
  let statfsResult = {
10
12
  bsize: 4096,
11
13
  blocks: 0,
@@ -34,6 +36,8 @@ mock.module("node:child_process", () => ({
34
36
 
35
37
  mock.module("../config/env-registry.js", () => ({
36
38
  getMinikubeStorageSize: () => minikubeStorageSize,
39
+ getIsContainerized: () => isContainerized,
40
+ getIsPlatform: () => isPlatform,
37
41
  }));
38
42
 
39
43
  mock.module("../util/platform.js", () => ({
@@ -55,6 +59,8 @@ describe("disk usage sampler", () => {
55
59
  beforeEach(() => {
56
60
  existingPaths = new Set([workspaceDir]);
57
61
  minikubeStorageSize = undefined;
62
+ isContainerized = false;
63
+ isPlatform = false;
58
64
  statfsResult = statfsFor(100 * MIB, 25 * MIB);
59
65
  spawnResult = { status: 0, stdout: "" };
60
66
  spawnCalls = [];
@@ -119,6 +125,65 @@ describe("disk usage sampler", () => {
119
125
  ]);
120
126
  });
121
127
 
128
+ test("measures workspace du usage on a local Docker hatch", () => {
129
+ isContainerized = true;
130
+ isPlatform = false;
131
+ statfsResult = statfsFor(100 * GIB, 40 * GIB);
132
+ spawnResult = {
133
+ status: 0,
134
+ stdout: `${2 * GIB}\t/workspace\n`,
135
+ };
136
+
137
+ const usage = getDiskUsageInfo();
138
+
139
+ // Used reflects only the workspace (du), free reflects the host headroom
140
+ // the volume can grow into, and total is their sum.
141
+ expect(usage).toEqual({
142
+ path: "/workspace",
143
+ totalMb: 2048 + 40960,
144
+ usedMb: 2048,
145
+ freeMb: 40960,
146
+ });
147
+ expect(spawnCalls).toEqual([
148
+ { command: "du", args: ["-sb", "/workspace"] },
149
+ ]);
150
+ });
151
+
152
+ test("does not use du for platform-managed containerized instances", () => {
153
+ isContainerized = true;
154
+ isPlatform = true;
155
+ statfsResult = statfsFor(10 * GIB, 6 * GIB);
156
+
157
+ const usage = getDiskUsageInfo();
158
+
159
+ expect(usage).toEqual({
160
+ path: "/workspace",
161
+ totalMb: 10240,
162
+ usedMb: 4096,
163
+ freeMb: 6144,
164
+ });
165
+ expect(spawnCalls).toHaveLength(0);
166
+ });
167
+
168
+ test("falls back to statfs when du fails on a local Docker hatch", () => {
169
+ isContainerized = true;
170
+ isPlatform = false;
171
+ statfsResult = statfsFor(100 * GIB, 40 * GIB);
172
+ spawnResult = { status: 1, stdout: "" };
173
+
174
+ const usage = getDiskUsageInfo();
175
+
176
+ expect(usage).toEqual({
177
+ path: "/workspace",
178
+ totalMb: 102400,
179
+ usedMb: 61440,
180
+ freeMb: 40960,
181
+ });
182
+ expect(spawnCalls).toEqual([
183
+ { command: "du", args: ["-sb", "/workspace"] },
184
+ ]);
185
+ });
186
+
122
187
  test("returns null for malformed Kubernetes memory strings", () => {
123
188
  expect(parseK8sMemoryBytes("")).toBeNull();
124
189
  expect(parseK8sMemoryBytes("abc")).toBeNull();
package/src/api/index.ts CHANGED
@@ -385,6 +385,8 @@ export {
385
385
  FeedItemDetailPanelKindSchema,
386
386
  FeedItemDetailPanelSchema,
387
387
  FeedItemSchema,
388
+ type FeedItemSourceType,
389
+ FeedItemSourceTypeSchema,
388
390
  type FeedItemStatus,
389
391
  FeedItemStatusSchema,
390
392
  type FeedItemType,
@@ -55,6 +55,25 @@ export const FeedItemCategorySchema = z.enum([
55
55
  ]);
56
56
  export type FeedItemCategory = z.infer<typeof FeedItemCategorySchema>;
57
57
 
58
+ /**
59
+ * Producer of a feed item's source conversation — lets clients filter the
60
+ * activity feed by what generated each notification: the periodic
61
+ * heartbeat, a memory-consolidation pass, a recurring schedule, an
62
+ * auto-analysis run, etc. Derived at read time from the source
63
+ * conversation's `source` column (see `home/feed-source-enrichment.ts`).
64
+ * Individual schedules are distinguished by `sourceKey`/`sourceLabel`, not
65
+ * this coarse type.
66
+ */
67
+ export const FeedItemSourceTypeSchema = z.enum([
68
+ "heartbeat",
69
+ "memory_consolidation",
70
+ "schedule",
71
+ "auto_analysis",
72
+ "user",
73
+ "other",
74
+ ]);
75
+ export type FeedItemSourceType = z.infer<typeof FeedItemSourceTypeSchema>;
76
+
58
77
  /**
59
78
  * A single action button attached to a feed item.
60
79
  *
@@ -120,6 +139,13 @@ export const FeedItemSchema = z.object({
120
139
  noteworthy: z.boolean().optional(),
121
140
  fromAssistant: z.boolean().optional(),
122
141
  metadata: z.record(z.string(), z.unknown()).optional(),
142
+ // Source-conversation classification, enriched at read time. `sourceKey`
143
+ // is the stable filter id — `schedule:<id>` for schedules so each filters
144
+ // separately, otherwise the `sourceType`. `sourceLabel` is the display
145
+ // string (a schedule's name, or a static label like "Heartbeat").
146
+ sourceType: FeedItemSourceTypeSchema.optional(),
147
+ sourceKey: z.string().optional(),
148
+ sourceLabel: z.string().optional(),
123
149
  createdAt: z.string(),
124
150
  });
125
151
  export type FeedItem = z.infer<typeof FeedItemSchema>;
@@ -1,4 +1,4 @@
1
- import { resolveCapabilities } from "../../../../runtime/capabilities.js";
1
+ import { isArchiveBySenderAuthorized } from "../../../../runtime/effective-capabilities.js";
2
2
  import type {
3
3
  ToolContext,
4
4
  ToolExecutionResult,
@@ -9,15 +9,14 @@ export async function run(
9
9
  input: Record<string, unknown>,
10
10
  context: ToolContext,
11
11
  ): Promise<ToolExecutionResult> {
12
- const userApproved =
13
- input.user_approved === true &&
14
- resolveCapabilities(context.trustClass).canSelfAuthorizeArchiveBySender;
15
- if (
16
- !context.triggeredBySurfaceAction &&
17
- !context.batchAuthorizedByTask &&
18
- !context.approvedViaPrompt &&
19
- !userApproved
20
- ) {
12
+ const authorized = isArchiveBySenderAuthorized({
13
+ trustClass: context.trustClass,
14
+ triggeredBySurfaceAction: context.triggeredBySurfaceAction,
15
+ batchAuthorizedByTask: context.batchAuthorizedByTask,
16
+ approvedViaPrompt: context.approvedViaPrompt,
17
+ userApproved: input.user_approved === true,
18
+ });
19
+ if (!authorized) {
21
20
  return err(
22
21
  "This tool requires either a surface action or a scheduled task run with this tool in required_tools. Present results in a selection table with action buttons and wait for the user to click before proceeding.",
23
22
  );
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { type FeedItem } from "../api/responses/home.js";
4
+ import {
5
+ classifyConversationSource,
6
+ enrichFeedItemsWithSource,
7
+ type FeedSourceEnrichmentDeps,
8
+ } from "./feed-source-enrichment.js";
9
+
10
+ function makeItem(partial: Partial<FeedItem> & Pick<FeedItem, "id">): FeedItem {
11
+ return {
12
+ type: "notification",
13
+ priority: 50,
14
+ summary: "summary",
15
+ timestamp: "2026-06-18T00:00:00.000Z",
16
+ createdAt: "2026-06-18T00:00:00.000Z",
17
+ status: "new",
18
+ ...partial,
19
+ };
20
+ }
21
+
22
+ describe("classifyConversationSource", () => {
23
+ it("maps each known producer source to its coarse type", () => {
24
+ expect(classifyConversationSource("heartbeat")).toBe("heartbeat");
25
+ expect(classifyConversationSource("memory-retrospective")).toBe(
26
+ "memory_consolidation",
27
+ );
28
+ expect(classifyConversationSource("memory-retrospective-fork")).toBe(
29
+ "memory_consolidation",
30
+ );
31
+ expect(classifyConversationSource("schedule")).toBe("schedule");
32
+ expect(classifyConversationSource("auto-analysis")).toBe("auto_analysis");
33
+ expect(classifyConversationSource("user")).toBe("user");
34
+ expect(classifyConversationSource("home-feed")).toBe("user");
35
+ });
36
+
37
+ it("falls through to 'other' for unknown / paired-delivery / empty sources", () => {
38
+ expect(classifyConversationSource("notification")).toBe("other");
39
+ expect(classifyConversationSource("something-else")).toBe("other");
40
+ expect(classifyConversationSource(null)).toBe("other");
41
+ expect(classifyConversationSource(undefined)).toBe("other");
42
+ });
43
+ });
44
+
45
+ describe("enrichFeedItemsWithSource", () => {
46
+ const deps = (
47
+ conv: Record<string, { source: string; scheduleJobId: string | null }>,
48
+ schedules: Record<string, string> = {},
49
+ ): FeedSourceEnrichmentDeps => ({
50
+ getConversationRow: (id) => conv[id] ?? null,
51
+ getScheduleName: (id) => schedules[id] ?? null,
52
+ });
53
+
54
+ it("classifies a heartbeat item with a static label", () => {
55
+ const [item] = enrichFeedItemsWithSource(
56
+ [makeItem({ id: "n1", conversationId: "c1" })],
57
+ deps({ c1: { source: "heartbeat", scheduleJobId: null } }),
58
+ );
59
+ expect(item.sourceType).toBe("heartbeat");
60
+ expect(item.sourceKey).toBe("heartbeat");
61
+ expect(item.sourceLabel).toBe("Heartbeat");
62
+ });
63
+
64
+ it("classifies a memory-consolidation item", () => {
65
+ const [item] = enrichFeedItemsWithSource(
66
+ [makeItem({ id: "n2", conversationId: "c2" })],
67
+ deps({ c2: { source: "memory-retrospective", scheduleJobId: null } }),
68
+ );
69
+ expect(item.sourceType).toBe("memory_consolidation");
70
+ expect(item.sourceKey).toBe("memory_consolidation");
71
+ expect(item.sourceLabel).toBe("Memory consolidation");
72
+ });
73
+
74
+ it("gives distinct keys and names to two different schedules", () => {
75
+ const [a, b] = enrichFeedItemsWithSource(
76
+ [
77
+ makeItem({ id: "n3", conversationId: "cA" }),
78
+ makeItem({ id: "n4", conversationId: "cB" }),
79
+ ],
80
+ deps(
81
+ {
82
+ cA: { source: "schedule", scheduleJobId: "sched-A" },
83
+ cB: { source: "schedule", scheduleJobId: "sched-B" },
84
+ },
85
+ { "sched-A": "Morning digest", "sched-B": "Evening recap" },
86
+ ),
87
+ );
88
+ expect(a.sourceType).toBe("schedule");
89
+ expect(a.sourceKey).toBe("schedule:sched-A");
90
+ expect(a.sourceLabel).toBe("Morning digest");
91
+ // scheduleId recovered from the conversation row is surfaced in metadata.
92
+ expect(a.metadata?.scheduleId).toBe("sched-A");
93
+
94
+ expect(b.sourceKey).toBe("schedule:sched-B");
95
+ expect(b.sourceLabel).toBe("Evening recap");
96
+ });
97
+
98
+ it("prefers an explicit metadata.scheduleId over the conversation row", () => {
99
+ const [item] = enrichFeedItemsWithSource(
100
+ [
101
+ makeItem({
102
+ id: "n5",
103
+ conversationId: "cX",
104
+ metadata: { scheduleId: "from-payload" },
105
+ }),
106
+ ],
107
+ deps(
108
+ { cX: { source: "schedule", scheduleJobId: "from-row" } },
109
+ { "from-payload": "Payload schedule" },
110
+ ),
111
+ );
112
+ expect(item.sourceKey).toBe("schedule:from-payload");
113
+ expect(item.sourceLabel).toBe("Payload schedule");
114
+ });
115
+
116
+ it("labels a schedule with no resolvable name as 'Scheduled'", () => {
117
+ const [item] = enrichFeedItemsWithSource(
118
+ [makeItem({ id: "n6", conversationId: "cY" })],
119
+ deps({ cY: { source: "schedule", scheduleJobId: "gone" } }),
120
+ );
121
+ expect(item.sourceKey).toBe("schedule:gone");
122
+ expect(item.sourceLabel).toBe("Scheduled");
123
+ });
124
+
125
+ it("classifies items with no source conversation as 'other'", () => {
126
+ const [item] = enrichFeedItemsWithSource(
127
+ [makeItem({ id: "n7" })],
128
+ deps({}),
129
+ );
130
+ expect(item.sourceType).toBe("other");
131
+ expect(item.sourceKey).toBe("other");
132
+ expect(item.sourceLabel).toBe("Other");
133
+ });
134
+
135
+ it("resolves each distinct conversation at most once", () => {
136
+ const calls: string[] = [];
137
+ enrichFeedItemsWithSource(
138
+ [
139
+ makeItem({ id: "n8", conversationId: "dup" }),
140
+ makeItem({ id: "n9", conversationId: "dup" }),
141
+ ],
142
+ {
143
+ getConversationRow: (id) => {
144
+ calls.push(id);
145
+ return { source: "heartbeat", scheduleJobId: null };
146
+ },
147
+ },
148
+ );
149
+ expect(calls).toEqual(["dup"]);
150
+ });
151
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Read-time enrichment of home-feed items with their source-conversation
3
+ * classification.
4
+ *
5
+ * The activity feed surfaces notifications produced by many background
6
+ * flows — the periodic heartbeat, memory-consolidation passes, each
7
+ * recurring schedule, auto-analysis runs, etc. Clients let the user filter
8
+ * the feed by that producer, so every item is tagged with:
9
+ *
10
+ * - `sourceType` — coarse producer category.
11
+ * - `sourceKey` — stable filter id (`schedule:<id>` per schedule,
12
+ * otherwise the `sourceType`).
13
+ * - `sourceLabel` — human-readable display (a schedule's name, or a
14
+ * static label such as "Heartbeat").
15
+ *
16
+ * The classification is derived from the source conversation's `source`
17
+ * column (and, for schedules, its name) rather than persisted onto the
18
+ * feed item. Resolving at read time keeps labels fresh across schedule
19
+ * renames and retroactively classifies items written before this feature
20
+ * existed, with no migration.
21
+ *
22
+ * The lookups are injectable so the enrichment can be unit-tested without
23
+ * a live database; production callers use the defaults.
24
+ */
25
+
26
+ import {
27
+ type FeedItem,
28
+ type FeedItemSourceType,
29
+ } from "../api/responses/home.js";
30
+ import { AUTO_ANALYSIS_SOURCE } from "../memory/auto-analysis-constants.js";
31
+ import { getConversation } from "../memory/conversation-crud.js";
32
+ import {
33
+ MEMORY_RETROSPECTIVE_FORK_SOURCE,
34
+ MEMORY_RETROSPECTIVE_SOURCE,
35
+ } from "../memory/memory-retrospective-constants.js";
36
+ import { getSchedule } from "../schedule/schedule-store.js";
37
+
38
+ /** Minimal source-conversation shape the enrichment needs. */
39
+ interface ConversationSourceRow {
40
+ source: string;
41
+ scheduleJobId: string | null;
42
+ }
43
+
44
+ export interface FeedSourceEnrichmentDeps {
45
+ /** Resolve the source columns for a conversation, or null when missing. */
46
+ getConversationRow?: (id: string) => ConversationSourceRow | null;
47
+ /** Resolve a schedule's display name, or null when missing. */
48
+ getScheduleName?: (id: string) => string | null;
49
+ }
50
+
51
+ /**
52
+ * Map a conversation's `source` column to a coarse feed source type.
53
+ * Mechanical mapping over the known producer sources; anything else
54
+ * (including the broadcaster's paired `"notification"` delivery
55
+ * conversations) falls through to `"other"`.
56
+ */
57
+ export function classifyConversationSource(
58
+ source: string | null | undefined,
59
+ ): FeedItemSourceType {
60
+ switch (source) {
61
+ case "heartbeat":
62
+ return "heartbeat";
63
+ case MEMORY_RETROSPECTIVE_SOURCE:
64
+ case MEMORY_RETROSPECTIVE_FORK_SOURCE:
65
+ return "memory_consolidation";
66
+ case "schedule":
67
+ return "schedule";
68
+ case AUTO_ANALYSIS_SOURCE:
69
+ return "auto_analysis";
70
+ case "user":
71
+ case "home-feed":
72
+ return "user";
73
+ default:
74
+ return "other";
75
+ }
76
+ }
77
+
78
+ /** Static display labels for non-schedule source types. */
79
+ const STATIC_SOURCE_LABELS: Record<
80
+ Exclude<FeedItemSourceType, "schedule">,
81
+ string
82
+ > = {
83
+ heartbeat: "Heartbeat",
84
+ memory_consolidation: "Memory consolidation",
85
+ auto_analysis: "Auto-analysis",
86
+ user: "Conversation",
87
+ other: "Other",
88
+ };
89
+
90
+ function defaultGetConversationRow(id: string): ConversationSourceRow | null {
91
+ try {
92
+ const row = getConversation(id);
93
+ return row
94
+ ? { source: row.source, scheduleJobId: row.scheduleJobId }
95
+ : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function defaultGetScheduleName(id: string): string | null {
102
+ try {
103
+ return getSchedule(id)?.name ?? null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Return a copy of each feed item enriched with `sourceType`, `sourceKey`,
111
+ * and `sourceLabel`. Conversation and schedule lookups are memoized across
112
+ * the batch so each distinct source is resolved at most once.
113
+ */
114
+ export function enrichFeedItemsWithSource(
115
+ items: FeedItem[],
116
+ deps: FeedSourceEnrichmentDeps = {},
117
+ ): FeedItem[] {
118
+ if (items.length === 0) return items;
119
+
120
+ const getConversationRow =
121
+ deps.getConversationRow ?? defaultGetConversationRow;
122
+ const getScheduleName = deps.getScheduleName ?? defaultGetScheduleName;
123
+
124
+ const convCache = new Map<string, ConversationSourceRow | null>();
125
+ const resolveConv = (id: string): ConversationSourceRow | null => {
126
+ if (!convCache.has(id)) convCache.set(id, getConversationRow(id));
127
+ return convCache.get(id) ?? null;
128
+ };
129
+
130
+ const scheduleNameCache = new Map<string, string | null>();
131
+ const resolveScheduleName = (id: string): string | null => {
132
+ if (!scheduleNameCache.has(id)) {
133
+ scheduleNameCache.set(id, getScheduleName(id));
134
+ }
135
+ return scheduleNameCache.get(id) ?? null;
136
+ };
137
+
138
+ return items.map((item) => {
139
+ const row = item.conversationId ? resolveConv(item.conversationId) : null;
140
+ const sourceType = classifyConversationSource(row?.source);
141
+
142
+ const metadataScheduleId =
143
+ typeof item.metadata?.scheduleId === "string"
144
+ ? item.metadata.scheduleId
145
+ : undefined;
146
+ const scheduleId = metadataScheduleId ?? row?.scheduleJobId ?? undefined;
147
+
148
+ let sourceKey: string;
149
+ let sourceLabel: string;
150
+ if (sourceType === "schedule" && scheduleId) {
151
+ sourceKey = `schedule:${scheduleId}`;
152
+ sourceLabel = resolveScheduleName(scheduleId) ?? "Scheduled";
153
+ } else if (sourceType === "schedule") {
154
+ sourceKey = "schedule";
155
+ sourceLabel = "Scheduled";
156
+ } else {
157
+ sourceKey = sourceType;
158
+ sourceLabel = STATIC_SOURCE_LABELS[sourceType];
159
+ }
160
+
161
+ // Surface the scheduleId in metadata when it was recovered from the
162
+ // source conversation so clients have a single place to read it.
163
+ const metadata =
164
+ scheduleId !== undefined && metadataScheduleId === undefined
165
+ ? { ...(item.metadata ?? {}), scheduleId }
166
+ : item.metadata;
167
+
168
+ return {
169
+ ...item,
170
+ sourceType,
171
+ sourceKey,
172
+ sourceLabel,
173
+ ...(metadata ? { metadata } : {}),
174
+ };
175
+ });
176
+ }
@@ -17,8 +17,10 @@
17
17
  * is the "what they may do once inside" axis.
18
18
  *
19
19
  * Context-dependent decisions (interactivity routing, self-approval races,
20
- * channel-specific overrides) COMPOSE these primitives with runtime context at
21
- * the call site — they are not encoded in the table.
20
+ * channel-specific overrides) COMPOSE these primitives with runtime context.
21
+ * They are not encoded in the table; named composition helpers live in
22
+ * `effective-capabilities.ts` (and `resolveRoutingState` in
23
+ * `trust-context-resolver.ts`).
22
24
  */
23
25
 
24
26
  import type { TrustClass } from "./actor-trust-resolver.js";
@@ -76,8 +78,9 @@ export interface CapabilitySet {
76
78
  */
77
79
  canAccessMemory: boolean;
78
80
  /**
79
- * May perform privileged (non-conversation-scoped) document operations.
80
- * Composed at the call site with the `vellum`-channel override.
81
+ * May perform privileged (non-conversation-scoped) document operations from
82
+ * trust class alone. The effective decision also honors privileged channels —
83
+ * see `canActOnPrivilegedDocuments` in `effective-capabilities.ts`.
81
84
  */
82
85
  canAccessPrivilegedDocuments: boolean;
83
86
 
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ canActOnPrivilegedDocuments,
5
+ isArchiveBySenderAuthorized,
6
+ isUntrustedShellLockdownActive,
7
+ } from "./effective-capabilities.js";
8
+
9
+ describe("canActOnPrivilegedDocuments", () => {
10
+ test("guardian is privileged regardless of channel", () => {
11
+ expect(canActOnPrivilegedDocuments({ trustClass: "guardian" })).toBe(true);
12
+ expect(
13
+ canActOnPrivilegedDocuments({
14
+ trustClass: "guardian",
15
+ executionChannel: "telegram",
16
+ }),
17
+ ).toBe(true);
18
+ });
19
+
20
+ test("non-guardian is privileged only on a privileged channel", () => {
21
+ expect(
22
+ canActOnPrivilegedDocuments({
23
+ trustClass: "trusted_contact",
24
+ executionChannel: "telegram",
25
+ }),
26
+ ).toBe(false);
27
+ expect(
28
+ canActOnPrivilegedDocuments({
29
+ trustClass: "trusted_contact",
30
+ executionChannel: "vellum",
31
+ }),
32
+ ).toBe(true);
33
+ });
34
+
35
+ test("the vellum channel grants access even to unknown actors", () => {
36
+ expect(
37
+ canActOnPrivilegedDocuments({
38
+ trustClass: "unknown",
39
+ executionChannel: "vellum",
40
+ }),
41
+ ).toBe(true);
42
+ expect(canActOnPrivilegedDocuments({ trustClass: "unknown" })).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("isUntrustedShellLockdownActive", () => {
47
+ test("inactive when the lockdown flag is off", () => {
48
+ expect(
49
+ isUntrustedShellLockdownActive({
50
+ trustClass: "unknown",
51
+ lockdownEnabled: false,
52
+ }),
53
+ ).toBe(false);
54
+ });
55
+
56
+ test("inactive for guardians even when the flag is on", () => {
57
+ expect(
58
+ isUntrustedShellLockdownActive({
59
+ trustClass: "guardian",
60
+ lockdownEnabled: true,
61
+ }),
62
+ ).toBe(false);
63
+ });
64
+
65
+ test("active for non-unsandboxed actors when the flag is on", () => {
66
+ expect(
67
+ isUntrustedShellLockdownActive({
68
+ trustClass: "trusted_contact",
69
+ lockdownEnabled: true,
70
+ }),
71
+ ).toBe(true);
72
+ expect(
73
+ isUntrustedShellLockdownActive({
74
+ trustClass: "unknown",
75
+ lockdownEnabled: true,
76
+ }),
77
+ ).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe("isArchiveBySenderAuthorized", () => {
82
+ test("any non-self authorization path suffices", () => {
83
+ expect(
84
+ isArchiveBySenderAuthorized({
85
+ trustClass: "unknown",
86
+ triggeredBySurfaceAction: true,
87
+ }),
88
+ ).toBe(true);
89
+ expect(
90
+ isArchiveBySenderAuthorized({
91
+ trustClass: "unknown",
92
+ batchAuthorizedByTask: true,
93
+ }),
94
+ ).toBe(true);
95
+ expect(
96
+ isArchiveBySenderAuthorized({
97
+ trustClass: "unknown",
98
+ approvedViaPrompt: true,
99
+ }),
100
+ ).toBe(true);
101
+ });
102
+
103
+ test("user_approved self-authorizes only for a permitted trust class", () => {
104
+ expect(
105
+ isArchiveBySenderAuthorized({
106
+ trustClass: "guardian",
107
+ userApproved: true,
108
+ }),
109
+ ).toBe(true);
110
+ expect(
111
+ isArchiveBySenderAuthorized({
112
+ trustClass: "trusted_contact",
113
+ userApproved: true,
114
+ }),
115
+ ).toBe(false);
116
+ });
117
+
118
+ test("unauthorized when no path applies", () => {
119
+ expect(isArchiveBySenderAuthorized({ trustClass: "guardian" })).toBe(false);
120
+ expect(
121
+ isArchiveBySenderAuthorized({
122
+ trustClass: "trusted_contact",
123
+ triggeredBySurfaceAction: false,
124
+ userApproved: false,
125
+ }),
126
+ ).toBe(false);
127
+ });
128
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Effective capabilities — trust-class capabilities COMPOSED with runtime context.
3
+ *
4
+ * `resolveCapabilities` (`capabilities.ts`) answers "what may this trust class
5
+ * do" from the actor's class alone, and stays deliberately pure (no context
6
+ * dependencies) so it remains the single fail-closed trust boundary. Some real
7
+ * decisions additionally depend on runtime context — the channel a request
8
+ * arrived on, surface actions, task authorization, a feature flag. Those
9
+ * compositions belong here, in named/testable helpers, rather than re-derived
10
+ * inline at each call site (which scatters a single policy across the codebase).
11
+ *
12
+ * Scope: this module composes capabilities with *actor/request* context. It does
13
+ * not read global config or feature flags itself — callers resolve those and
14
+ * pass the result in (e.g. `lockdownEnabled`), keeping this layer focused on the
15
+ * capability composition. `resolveRoutingState` in `trust-context-resolver.ts`
16
+ * is the same shape (capability + guardian-route context → `promptWaitingAllowed`)
17
+ * and predates this module; it stays where it is.
18
+ */
19
+ import type { TrustClass } from "./actor-trust-resolver.js";
20
+ import { resolveCapabilities } from "./capabilities.js";
21
+
22
+ type RawTrustClass = TrustClass | (string & {}) | undefined;
23
+
24
+ /**
25
+ * Channels that are themselves privileged document surfaces. Actors on these get
26
+ * privileged document access regardless of trust class — the `vellum` first-party
27
+ * console is the operator's own surface, not an external contact channel.
28
+ */
29
+ const PRIVILEGED_DOCUMENT_CHANNELS = new Set<string>(["vellum"]);
30
+
31
+ /**
32
+ * Whether an actor may perform privileged (non-conversation-scoped) document
33
+ * operations. True when the trust class grants it OR the request arrived on a
34
+ * privileged channel.
35
+ */
36
+ export function canActOnPrivilegedDocuments(actor: {
37
+ trustClass: RawTrustClass;
38
+ executionChannel?: string;
39
+ }): boolean {
40
+ return (
41
+ resolveCapabilities(actor.trustClass).canAccessPrivilegedDocuments ||
42
+ (actor.executionChannel != null &&
43
+ PRIVILEGED_DOCUMENT_CHANNELS.has(actor.executionChannel))
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Whether the CES shell lockdown applies to this actor. Active when the lockdown
49
+ * flag is enabled AND the actor's trust class cannot run an unsandboxed shell.
50
+ * Callers resolve the flag (`isCesShellLockdownEnabled(config)`) and pass it in.
51
+ */
52
+ export function isUntrustedShellLockdownActive(actor: {
53
+ trustClass: RawTrustClass;
54
+ lockdownEnabled: boolean;
55
+ }): boolean {
56
+ return (
57
+ actor.lockdownEnabled &&
58
+ !resolveCapabilities(actor.trustClass).canRunUnsandboxedShell
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Whether an archive-by-sender invocation is authorized. Any one of a surface
64
+ * action, a task-batch authorization, or an explicit prompt approval suffices.
65
+ * Absent those, the actor's own `user_approved` flag only counts when its trust
66
+ * class may self-authorize archive-by-sender.
67
+ */
68
+ export function isArchiveBySenderAuthorized(args: {
69
+ trustClass: RawTrustClass;
70
+ triggeredBySurfaceAction?: boolean;
71
+ batchAuthorizedByTask?: boolean;
72
+ approvedViaPrompt?: boolean;
73
+ userApproved?: boolean;
74
+ }): boolean {
75
+ const selfAuthorized =
76
+ args.userApproved === true &&
77
+ resolveCapabilities(args.trustClass).canSelfAuthorizeArchiveBySender;
78
+ return (
79
+ args.triggeredBySurfaceAction === true ||
80
+ args.batchAuthorizedByTask === true ||
81
+ args.approvedViaPrompt === true ||
82
+ selfAuthorized
83
+ );
84
+ }
@@ -28,6 +28,7 @@ import {
28
28
  type FeedItemStatus,
29
29
  HomeFeedResponseSchema,
30
30
  } from "../../api/responses/home.js";
31
+ import { enrichFeedItemsWithSource } from "../../home/feed-source-enrichment.js";
31
32
  import { patchFeedItemStatus, readHomeFeed } from "../../home/feed-writer.js";
32
33
  import { revalidateHomeContentInBackground } from "../../home/home-content-refresh.js";
33
34
  import { getPersonalizedGreeting } from "../../home/home-greeting.js";
@@ -135,8 +136,10 @@ export async function handleGetHomeFeed({
135
136
  // v2 schema dropped per-item `minTimeAway` gating; surface every item
136
137
  // and let the client decide what to render based on its own
137
138
  // session state. `timeAwaySeconds` survives only to feed the
138
- // context-banner relative-time label.
139
- const filtered = feed.items;
139
+ // context-banner relative-time label. Each item is enriched with its
140
+ // source-conversation classification (`sourceType`/`sourceKey`/
141
+ // `sourceLabel`) so clients can filter the feed by what produced it.
142
+ const filtered = enrichFeedItemsWithSource(feed.items);
140
143
 
141
144
  const now = new Date();
142
145
 
@@ -287,7 +290,9 @@ export function handleListHomeFeed({
287
290
  const total = filtered.length;
288
291
  const offset = params.offset ?? 0;
289
292
  const limit = params.limit ?? 20;
290
- const items = filtered.slice(offset, offset + limit);
293
+ const items = enrichFeedItemsWithSource(
294
+ filtered.slice(offset, offset + limit),
295
+ );
291
296
 
292
297
  return {
293
298
  items,
@@ -13,14 +13,11 @@ import {
13
13
  searchDocumentsByTitle,
14
14
  updateDocumentContent,
15
15
  } from "../../documents/document-store.js";
16
- import { resolveCapabilities } from "../../runtime/capabilities.js";
16
+ import { canActOnPrivilegedDocuments } from "../../runtime/effective-capabilities.js";
17
17
  import type { ToolContext, ToolExecutionResult } from "../types.js";
18
18
 
19
19
  function isPrivilegedDocumentActor(context: ToolContext): boolean {
20
- return (
21
- resolveCapabilities(context.trustClass).canAccessPrivilegedDocuments ||
22
- context.executionChannel === "vellum"
23
- );
20
+ return canActOnPrivilegedDocuments(context);
24
21
  }
25
22
 
26
23
  export function documentNotFound(surfaceId: string): ToolExecutionResult {
@@ -5,7 +5,7 @@ import { bridgeCesApproval } from "../credential-execution/approval-bridge.js";
5
5
  import { isCesShellLockdownEnabled } from "../credential-execution/feature-gates.js";
6
6
  import { PermissionPrompter } from "../permissions/prompter.js";
7
7
  import { RiskLevel } from "../permissions/types.js";
8
- import { resolveCapabilities } from "../runtime/capabilities.js";
8
+ import { isUntrustedShellLockdownActive } from "../runtime/effective-capabilities.js";
9
9
  import { redactSensitiveFields } from "../security/redaction.js";
10
10
  import { getCesClient } from "../security/secure-keys.js";
11
11
  import { TokenExpiredError } from "../security/token-manager.js";
@@ -128,8 +128,10 @@ export class ToolExecutor {
128
128
  // skip this assignment entirely - defeating the lockdown.
129
129
  if (
130
130
  name === "host_bash" &&
131
- isCesShellLockdownEnabled(getConfig()) &&
132
- !resolveCapabilities(context.trustClass).canRunUnsandboxedShell
131
+ isUntrustedShellLockdownActive({
132
+ trustClass: context.trustClass,
133
+ lockdownEnabled: isCesShellLockdownEnabled(getConfig()),
134
+ })
133
135
  ) {
134
136
  context.forcePromptSideEffects = true;
135
137
  }
@@ -25,7 +25,7 @@ import { HostBashProxy } from "../../daemon/host-bash-proxy.js";
25
25
  import { RiskLevel } from "../../permissions/types.js";
26
26
  import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
27
27
  import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
28
- import { resolveCapabilities } from "../../runtime/capabilities.js";
28
+ import { isUntrustedShellLockdownActive } from "../../runtime/effective-capabilities.js";
29
29
  import { redactSecrets } from "../../security/secret-scanner.js";
30
30
  import { getLogger } from "../../util/logger.js";
31
31
  import {
@@ -204,9 +204,10 @@ export const hostShellTool = {
204
204
  // NOTE: forcePromptSideEffects is set in executor.ts BEFORE the
205
205
  // permission check runs, not here. Setting it here would be too late
206
206
  // because execute() is called after permissions have already been evaluated.
207
- const hostLockdownActive =
208
- isCesShellLockdownEnabled(config) &&
209
- !resolveCapabilities(context.trustClass).canRunUnsandboxedShell;
207
+ const hostLockdownActive = isUntrustedShellLockdownActive({
208
+ trustClass: context.trustClass,
209
+ lockdownEnabled: isCesShellLockdownEnabled(config),
210
+ });
210
211
 
211
212
  // Guard: non-host-proxy interfaces need an explicit target when multiple
212
213
  // capable clients are connected to avoid ambiguous untargeted broadcasts.
@@ -5,7 +5,7 @@ import { getConfig } from "../../config/loader.js";
5
5
  import { isCesShellLockdownEnabled } from "../../credential-execution/feature-gates.js";
6
6
  import { RiskLevel } from "../../permissions/types.js";
7
7
  import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
8
- import { resolveCapabilities } from "../../runtime/capabilities.js";
8
+ import { isUntrustedShellLockdownActive } from "../../runtime/effective-capabilities.js";
9
9
  import { redactSecrets } from "../../security/secret-scanner.js";
10
10
  import { getLogger } from "../../util/logger.js";
11
11
  import { getDataDir } from "../../util/platform.js";
@@ -117,9 +117,10 @@ export const shellTool = {
117
117
  }
118
118
 
119
119
  const config = getConfig();
120
- const shellLockdownActive =
121
- isCesShellLockdownEnabled(config) &&
122
- !resolveCapabilities(context.trustClass).canRunUnsandboxedShell;
120
+ const shellLockdownActive = isUntrustedShellLockdownActive({
121
+ trustClass: context.trustClass,
122
+ lockdownEnabled: isCesShellLockdownEnabled(config),
123
+ });
123
124
 
124
125
  const networkMode: "off" | "proxied" =
125
126
  input.network_mode === "proxied" ? "proxied" : "off";
@@ -1,7 +1,11 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, statfsSync } from "node:fs";
3
3
 
4
- import { getMinikubeStorageSize } from "../config/env-registry.js";
4
+ import {
5
+ getIsContainerized,
6
+ getIsPlatform,
7
+ getMinikubeStorageSize,
8
+ } from "../config/env-registry.js";
5
9
  import { getWorkspaceDir } from "./platform.js";
6
10
 
7
11
  export interface DiskUsageInfo {
@@ -58,6 +62,57 @@ export function __resetDiskUsageCacheForTests(): void {
58
62
  duCachePaths = null;
59
63
  }
60
64
 
65
+ /**
66
+ * How the workspace volume's capacity should be reported when statfsSync
67
+ * cannot see the volume directly.
68
+ *
69
+ * - `fixed-cap`: minikube hostPath PVC — the PVC request is the hard
70
+ * capacity, so usage is measured with `du` and free space is whatever
71
+ * remains within the quota.
72
+ * - `host-free`: local Docker named volume — the volume is backed by the
73
+ * host (or Colima VM) filesystem and can grow into its free space, so
74
+ * usage is measured with `du` and the effective capacity is current usage
75
+ * plus the host's remaining headroom.
76
+ * - `none`: statfsSync already reports the volume accurately (bare metal, or
77
+ * a platform-managed instance on a CSI-backed PVC), so use it directly.
78
+ */
79
+ type WorkspaceVolumeReporting =
80
+ | { kind: "none" }
81
+ | { kind: "fixed-cap"; totalBytes: number }
82
+ | { kind: "host-free" };
83
+
84
+ /**
85
+ * Decide how to report the workspace volume's capacity. Both minikube
86
+ * hostPath PVCs and local Docker named volumes are backed by the host
87
+ * filesystem, so statfsSync reports the host's entire disk rather than the
88
+ * workspace volume — in both cases we measure actual usage with `du` instead.
89
+ */
90
+ function classifyWorkspaceVolume(
91
+ fsTotalBytes: number,
92
+ ): WorkspaceVolumeReporting {
93
+ // Minikube mode: the platform passes the PVC storage size so we can report
94
+ // accurate capacity. Detect the hostPath case by comparing filesystem size
95
+ // against the PVC size — if the filesystem is larger, statfsSync is seeing
96
+ // the host disk and we should measure the directory instead.
97
+ const storageSizeRaw = getMinikubeStorageSize();
98
+ if (storageSizeRaw) {
99
+ const pvcTotalBytes = parseK8sMemoryBytes(storageSizeRaw);
100
+ if (pvcTotalBytes !== null && fsTotalBytes > pvcTotalBytes * 1.1) {
101
+ return { kind: "fixed-cap", totalBytes: pvcTotalBytes };
102
+ }
103
+ return { kind: "none" };
104
+ }
105
+
106
+ // Local Docker hatch: the workspace is a Docker named volume backed by the
107
+ // host filesystem. Platform-managed remote instances run on CSI-backed PVCs
108
+ // where statfsSync already reports the volume, so they are excluded.
109
+ if (getIsContainerized() && !getIsPlatform()) {
110
+ return { kind: "host-free" };
111
+ }
112
+
113
+ return { kind: "none" };
114
+ }
115
+
61
116
  export function getDiskUsageInfo(): DiskUsageInfo | null {
62
117
  try {
63
118
  const wsDir = getWorkspaceDir();
@@ -68,28 +123,28 @@ export function getDiskUsageInfo(): DiskUsageInfo | null {
68
123
  const bytesToMb = (b: number) =>
69
124
  Math.round((b / (1024 * 1024)) * 100) / 100;
70
125
 
71
- // Minikube mode: the platform passes the PVC storage size so we can
72
- // report accurate capacity. On hostPath-backed PVCs statfsSync reports
73
- // the host's entire filesystem rather than the PVC. Detect this by
74
- // comparing filesystem size against PVC size — if the filesystem is
75
- // larger, measure actual directory usage with `du` instead.
76
- const storageSizeRaw = getMinikubeStorageSize();
77
- if (storageSizeRaw) {
78
- const pvcTotalBytes = parseK8sMemoryBytes(storageSizeRaw);
79
- if (pvcTotalBytes !== null && fsTotalBytes > pvcTotalBytes * 1.1) {
80
- const volumePaths = [diskPath];
81
- if (diskPath !== "/data" && existsSync("/data")) {
82
- volumePaths.push("/data");
83
- }
84
- const usedBytes = getCachedDirectorySizeBytes(volumePaths);
85
- if (usedBytes !== null) {
86
- return {
87
- path: diskPath,
88
- totalMb: bytesToMb(pvcTotalBytes),
89
- usedMb: bytesToMb(usedBytes),
90
- freeMb: bytesToMb(Math.max(0, pvcTotalBytes - usedBytes)),
91
- };
92
- }
126
+ const reporting = classifyWorkspaceVolume(fsTotalBytes);
127
+ if (reporting.kind !== "none") {
128
+ const volumePaths = [diskPath];
129
+ if (diskPath !== "/data" && existsSync("/data")) {
130
+ volumePaths.push("/data");
131
+ }
132
+ const usedBytes = getCachedDirectorySizeBytes(volumePaths);
133
+ if (usedBytes !== null) {
134
+ const totalBytes =
135
+ reporting.kind === "fixed-cap"
136
+ ? reporting.totalBytes
137
+ : usedBytes + fsFreeBytes;
138
+ const freeBytes =
139
+ reporting.kind === "fixed-cap"
140
+ ? Math.max(0, reporting.totalBytes - usedBytes)
141
+ : fsFreeBytes;
142
+ return {
143
+ path: diskPath,
144
+ totalMb: bytesToMb(totalBytes),
145
+ usedMb: bytesToMb(usedBytes),
146
+ freeMb: bytesToMb(freeBytes),
147
+ };
93
148
  }
94
149
  }
95
150