@camstack/server 1.2.606 → 1.2.607

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.
@@ -719,6 +719,28 @@ function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRoot
719
719
  function ownerSecrets(ar, owner) {
720
720
  return typeof ar.secretsForOwner === 'function' ? ar.secretsForOwner(owner) : null;
721
721
  }
722
+ /**
723
+ * The integration settings keys the addon's own form stores in the keychain
724
+ * (by schema type, D783) — so the admin read can withhold a plaintext under one
725
+ * of them. Empty when the schema cannot be read (logged): markers are still
726
+ * withheld, and a coerced marker is caught without a schema.
727
+ */
728
+ async function integrationSecretKeys(ar, addonId, logger) {
729
+ try {
730
+ const provider = ar
731
+ .getCapabilityRegistry()
732
+ .getSingleton('addon-settings');
733
+ const schema = await provider?.getGlobalSettings({ addonId, nodeId: 'hub' });
734
+ return (0, types_1.collectKeychainStoredKeys)(schema ?? null);
735
+ }
736
+ catch (err) {
737
+ logger.warn('integration settings: addon schema unreadable — plaintext secrets not detectable', {
738
+ tags: { addonId },
739
+ meta: { phase: 'getSettings', error: (0, types_1.errMsg)(err) },
740
+ });
741
+ return new Set();
742
+ }
743
+ }
722
744
  function requireIntegrationRegistry(ar) {
723
745
  const reg = ar.getIntegrationRegistry();
724
746
  if (!reg)
@@ -1143,7 +1165,22 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
1143
1165
  const caller = Reflect.get(input, 'callerAddonId');
1144
1166
  const forOwner = typeof caller === 'string' &&
1145
1167
  (caller === integration.addonId || ar.runnerIdFor(integration.addonId) === caller);
1146
- return (0, integration_secrets_1.readIntegrationSettings)({ registry: reg, secrets: forOwner ? ownerSecrets(ar, integration.addonId) : null }, { integrationId: input.id, forOwner });
1168
+ // The schema is asked only for the admin read: the owner reads this at
1169
+ // its own boot, and asking it for its schema then would call back into it.
1170
+ const secretKeys = forOwner
1171
+ ? undefined
1172
+ : await integrationSecretKeys(ar, integration.addonId, logger);
1173
+ return (0, integration_secrets_1.readIntegrationSettings)({ registry: reg, secrets: forOwner ? ownerSecrets(ar, integration.addonId) : null }, {
1174
+ integrationId: input.id,
1175
+ forOwner,
1176
+ ...(secretKeys !== undefined ? { secretKeys } : {}),
1177
+ onSuspectSecret: (suspect) => logger.error(suspect.kind === 'coerced-marker'
1178
+ ? 'integration secret is "[object Object]" — a keychain marker was coerced to text; the addon is sending it as the credential (repair: scripts/hub/repair-integration-secret-markers.ts, or re-type it)'
1179
+ : 'integration secret stored as PLAINTEXT under a keychain-declared key — withheld from the form; re-type it to move it into the keychain', {
1180
+ tags: { integrationId: input.id, addonId: integration.addonId },
1181
+ meta: { phase: 'getSettings', key: suspect.key, kind: suspect.kind, forOwner },
1182
+ }),
1183
+ });
1147
1184
  },
1148
1185
  setSettings: async (input) => {
1149
1186
  const reg = requireIntegrationRegistry(ar);
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COERCED_MARKER_TEXT = void 0;
3
4
  exports.writeIntegrationSettings = writeIntegrationSettings;
4
5
  exports.readIntegrationSettings = readIntegrationSettings;
5
6
  /**
@@ -45,16 +46,36 @@ async function writeIntegrationSettings(deps, input) {
45
46
  if (orphaned.length > 0)
46
47
  deps.onOrphaned?.(orphaned);
47
48
  }
49
+ /** The text `String(...)` makes of an object — a keychain marker some copy coerced. */
50
+ exports.COERCED_MARKER_TEXT = '[object Object]';
51
+ function suspectKind(key, value, secretKeys) {
52
+ if (value === exports.COERCED_MARKER_TEXT)
53
+ return 'coerced-marker';
54
+ if (secretKeys?.has(key) === true && typeof value === 'string' && value.length > 0) {
55
+ return 'plaintext';
56
+ }
57
+ return null;
58
+ }
48
59
  async function readIntegrationSettings(deps, input) {
49
60
  const stored = await deps.registry.getIntegrationSettings(input.integrationId);
61
+ const suspects = new Set();
62
+ for (const [key, value] of Object.entries(stored)) {
63
+ const kind = suspectKind(key, value, input.secretKeys);
64
+ if (kind === null)
65
+ continue;
66
+ suspects.add(key);
67
+ input.onSuspectSecret?.({ key, kind });
68
+ }
50
69
  if (input.forOwner) {
51
70
  if (deps.secrets === null)
52
71
  return stored;
53
72
  return (await (0, types_1.resolveSecretMarkers)(stored, deps.secrets)).value;
54
73
  }
74
+ // Anyone but the owner — the admin form — never receives a secret's text:
75
+ // a marker becomes its "set" view, and so does a suspect plaintext.
55
76
  const out = {};
56
77
  for (const [key, value] of Object.entries(stored)) {
57
- out[key] = (0, types_1.isSecretMarker)(value) ? (0, types_1.toSecretFieldView)(value) : value;
78
+ out[key] = (0, types_1.isSecretMarker)(value) || suspects.has(key) ? (0, types_1.toSecretFieldView)(value) : value;
58
79
  }
59
80
  return out;
60
81
  }
@@ -11,7 +11,9 @@ exports.createLogsRouter = createLogsRouter;
11
11
  */
12
12
  const zod_1 = require("zod");
13
13
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
14
- const types_1 = require("@camstack/types");
14
+ const node_crypto_1 = require("node:crypto");
15
+ const system_1 = require("@camstack/system");
16
+ const log_history_js_1 = require("../../core/logging/log-history.js");
15
17
  const LogLevelSchema = zod_1.z.enum(['debug', 'info', 'warn', 'error']);
16
18
  const LogTagsSchema = zod_1.z.object({
17
19
  agentId: zod_1.z.string().optional(),
@@ -39,6 +41,8 @@ const LogTagsSchema = zod_1.z.object({
39
41
  });
40
42
  /** Wire shape of a log entry — timestamp is serialised to ISO string by tRPC. */
41
43
  const LogEntrySchema = zod_1.z.object({
44
+ /** `<bootId>.<seq>` — identical live and on disk; the client's dedupe key (D898). */
45
+ id: zod_1.z.string().optional(),
42
46
  timestamp: zod_1.z.string(),
43
47
  level: zod_1.z.string(),
44
48
  scope: zod_1.z.string().optional(),
@@ -53,63 +57,214 @@ const LogEntrySchema = zod_1.z.object({
53
57
  meta: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional(),
54
58
  });
55
59
  const LogEntryArraySchema = zod_1.z.array(LogEntrySchema);
56
- function createLogsRouter(logging) {
60
+ /** Wire shape of a history page (D898). */
61
+ const LogPageSchema = zod_1.z.object({
62
+ entries: LogEntryArraySchema,
63
+ /** Pass back as `before` to load the next OLDER page; null = nothing older. */
64
+ nextBefore: zod_1.z.string().nullable(),
65
+ nextAfter: zod_1.z.string().nullable(),
66
+ /** Bytes the source scanned for this slice — the "N MB scanned" indicator. */
67
+ scannedBytes: zod_1.z.number().int().nonnegative(),
68
+ /**
69
+ * True when the source has nothing older. False with fewer than `limit`
70
+ * entries = the slice's time budget ran out first; ask again with
71
+ * `nextBefore` to keep searching.
72
+ */
73
+ done: zod_1.z.boolean(),
74
+ cancelled: zod_1.z.boolean().optional(),
75
+ /** The source that answered — `ring` when the requested one cannot read. */
76
+ source: zod_1.z.string(),
77
+ });
78
+ const LogSourceSchema = zod_1.z.object({
79
+ addonId: zod_1.z.string(),
80
+ readable: zod_1.z.boolean(),
81
+ reason: zod_1.z.string().nullable(),
82
+ isDefault: zod_1.z.boolean(),
83
+ retentionDays: zod_1.z.number().nullable(),
84
+ filters: zod_1.z.object({
85
+ deviceId: zod_1.z.boolean(),
86
+ addonId: zod_1.z.boolean(),
87
+ nodeId: zod_1.z.boolean(),
88
+ tags: zod_1.z.boolean(),
89
+ level: zod_1.z.boolean(),
90
+ text: zod_1.z.boolean(),
91
+ timeRange: zod_1.z.boolean(),
92
+ }),
93
+ });
94
+ /** `tags` (the LogStream scope) → the read contract's indexed fields + the rest. */
95
+ function readFiltersFromTags(tags) {
96
+ if (tags === undefined)
97
+ return {};
98
+ const rest = {};
99
+ let deviceId;
100
+ let addonId;
101
+ let nodeId;
102
+ for (const [key, raw] of Object.entries(tags)) {
103
+ if (raw === undefined)
104
+ continue;
105
+ if (key === 'deviceId') {
106
+ const n = typeof raw === 'number' ? raw : Number(raw);
107
+ if (Number.isInteger(n))
108
+ deviceId = n;
109
+ continue;
110
+ }
111
+ if (key === 'addonId' && typeof raw === 'string') {
112
+ addonId = raw;
113
+ continue;
114
+ }
115
+ if (key === 'agentId' && typeof raw === 'string') {
116
+ nodeId = raw;
117
+ continue;
118
+ }
119
+ if (key === 'containerDeviceId') {
120
+ const n = typeof raw === 'number' ? raw : Number(raw);
121
+ rest[key] = Number.isInteger(n) ? n : String(raw);
122
+ continue;
123
+ }
124
+ rest[key] = raw;
125
+ }
126
+ return {
127
+ ...(deviceId !== undefined ? { deviceId } : {}),
128
+ ...(addonId !== undefined ? { fromAddonId: addonId } : {}),
129
+ ...(nodeId !== undefined ? { fromNodeId: nodeId } : {}),
130
+ ...(Object.keys(rest).length > 0 ? { tags: rest } : {}),
131
+ };
132
+ }
133
+ function toWire(entry) {
134
+ return {
135
+ ...(entry.id !== undefined ? { id: entry.id } : {}),
136
+ timestamp: new Date(entry.timestamp).toISOString(),
137
+ level: entry.level,
138
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
139
+ message: entry.message,
140
+ ...(entry.tags !== undefined
141
+ ? {
142
+ tags: Object.fromEntries(Object.entries(entry.tags).map(([k, v]) => [k, String(v)])),
143
+ }
144
+ : {}),
145
+ ...(entry.meta !== undefined ? { meta: entry.meta } : {}),
146
+ };
147
+ }
148
+ /** Hub-local providers only: an agent's own files hold lines the hub already has. */
149
+ function hubLocalDestinations(registry) {
150
+ if (registry === null)
151
+ return [];
152
+ return registry
153
+ .getCollectionEntries('log-destination')
154
+ .filter(([addonId]) => !addonId.includes('@'));
155
+ }
156
+ const NOT_READABLE = {
157
+ readable: false,
158
+ reason: 'This destination only ships lines',
159
+ retentionDays: null,
160
+ filters: {
161
+ deviceId: false,
162
+ addonId: false,
163
+ nodeId: false,
164
+ tags: false,
165
+ level: false,
166
+ text: false,
167
+ timeRange: false,
168
+ },
169
+ };
170
+ function createLogsRouter(logging, capabilityRegistry = null) {
57
171
  return (0, trpc_middleware_js_1.trpcRouter)({
172
+ /**
173
+ * One page of history (D898): newest first, from the source the client
174
+ * picked (default the hub's rotated files), every filter applied by the
175
+ * source BEFORE the limit. Page older with `before: nextBefore`. A slice
176
+ * is time-bounded — `done: false` with a short page means "still
177
+ * searching", and aborting the request cancels the slice in flight.
178
+ */
58
179
  query: trpc_middleware_js_1.adminProcedure
59
180
  .input(zod_1.z.object({
181
+ /** A read-capable log-destination addonId, or `ring`. Default `winston-logging`. */
182
+ source: zod_1.z.string().min(1).max(200).optional(),
60
183
  level: LogLevelSchema.optional(),
61
184
  since: zod_1.z.number().optional(),
62
185
  until: zod_1.z.number().optional(),
63
- limit: zod_1.z.number().optional(),
186
+ limit: zod_1.z.number().int().min(1).max(1000).optional(),
64
187
  tags: LogTagsSchema.optional(),
188
+ /** Case-insensitive substring over message, scope, tags and meta. */
189
+ text: zod_1.z.string().max(200).optional(),
190
+ before: zod_1.z.string().min(1).max(1024).optional(),
191
+ after: zod_1.z.string().min(1).max(1024).optional(),
192
+ budgetMs: zod_1.z.number().int().min(50).max(10_000).optional(),
65
193
  }))
66
- .output(LogEntryArraySchema)
67
- .query(({ input, ctx }) => {
68
- const filter = { limit: input.limit ?? 100 };
69
- if (input.level)
70
- filter.level = input.level;
71
- if (input.since !== undefined)
72
- filter.since = new Date(input.since);
73
- if (input.until !== undefined)
74
- filter.until = new Date(input.until);
75
- if (input.tags)
76
- filter.tags = input.tags;
77
- const entries = logging.query(filter);
78
- const filtered = (() => {
79
- if (!ctx.user.isAdmin) {
80
- const ap = ctx.user.permissions?.allowedProviders;
81
- if (ap && ap !== '*') {
82
- const allowed = ap;
83
- return entries.filter((e) => {
84
- const addonId = e.tags?.addonId;
85
- if (typeof addonId !== 'string')
86
- return false;
87
- return allowed.some((p) => {
88
- const bare = (0, types_1.bareAddonId)(p);
89
- return addonId === bare || addonId.startsWith(bare);
90
- });
91
- });
92
- }
194
+ .output(LogPageSchema)
195
+ .query(async ({ input, signal }) => {
196
+ const sourceId = input.source ?? log_history_js_1.DEFAULT_LOG_SOURCE;
197
+ const provider = sourceId === log_history_js_1.RING_LOG_SOURCE
198
+ ? null
199
+ : (hubLocalDestinations(capabilityRegistry).find(([id]) => id === sourceId)?.[1] ??
200
+ null);
201
+ const requestId = (0, node_crypto_1.randomUUID)();
202
+ const text = input.text?.trim();
203
+ const query = {
204
+ requestId,
205
+ ...readFiltersFromTags(input.tags),
206
+ ...(input.level !== undefined ? { level: input.level } : {}),
207
+ ...(text !== undefined && text.length > 0 ? { text } : {}),
208
+ ...(input.since !== undefined ? { since: input.since } : {}),
209
+ ...(input.until !== undefined ? { until: input.until } : {}),
210
+ ...(input.before !== undefined ? { before: input.before } : {}),
211
+ ...(input.after !== undefined ? { after: input.after } : {}),
212
+ ...(input.budgetMs !== undefined ? { budgetMs: input.budgetMs } : {}),
213
+ limit: input.limit ?? log_history_js_1.DEFAULT_PAGE_LIMIT,
214
+ };
215
+ const onAbort = () => {
216
+ void provider?.cancelRead?.({ requestId }).catch(() => undefined);
217
+ };
218
+ signal?.addEventListener('abort', onAbort, { once: true });
219
+ try {
220
+ const page = await (0, log_history_js_1.readLogHistory)({
221
+ query,
222
+ source: sourceId,
223
+ tail: logging.query({}),
224
+ provider,
225
+ });
226
+ return {
227
+ entries: page.entries.map(toWire),
228
+ nextBefore: page.nextBefore,
229
+ nextAfter: page.nextAfter,
230
+ scannedBytes: page.scannedBytes,
231
+ done: page.done,
232
+ ...(page.cancelled !== undefined ? { cancelled: page.cancelled } : {}),
233
+ source: page.source,
234
+ };
235
+ }
236
+ finally {
237
+ signal?.removeEventListener('abort', onAbort);
238
+ }
239
+ }),
240
+ /**
241
+ * The history sources the Logs page can offer: every hub-local
242
+ * log-destination with what it can read. A destination without
243
+ * `describeRead` is listed as not readable, never omitted — "ships only"
244
+ * is an answer, "not installed" is a different one.
245
+ */
246
+ sources: trpc_middleware_js_1.adminProcedure.output(zod_1.z.array(LogSourceSchema)).query(async () => {
247
+ const out = [];
248
+ for (const [addonId, dest] of hubLocalDestinations(capabilityRegistry)) {
249
+ const isDefault = addonId === log_history_js_1.DEFAULT_LOG_SOURCE;
250
+ if (dest.describeRead === undefined) {
251
+ out.push({ addonId, isDefault, ...NOT_READABLE });
252
+ continue;
253
+ }
254
+ try {
255
+ const caps = await dest.describeRead();
256
+ out.push({ addonId, isDefault, ...caps });
93
257
  }
94
- return entries;
95
- })();
96
- return filtered.map((e) => ({
97
- timestamp: e.timestamp instanceof Date ? e.timestamp.toISOString() : String(e.timestamp),
98
- level: e.level,
99
- ...(e.scope !== undefined ? { scope: e.scope } : {}),
100
- message: e.message,
101
- // Tags carry mixed primitive types (numeric deviceId is common —
102
- // see kernel/capability-registry.ts logging calls). The wire
103
- // schema is Record<string,string>, so stringify here. Mirrors
104
- // the same normalisation applied in `subscribe` below.
105
- tags: e.tags
106
- ? Object.fromEntries(Object.entries(e.tags).map(([k, v]) => [
107
- k,
108
- v === undefined ? undefined : String(v),
109
- ]))
110
- : undefined,
111
- ...(e.meta !== undefined ? { meta: e.meta } : {}),
112
- }));
258
+ catch (error) {
259
+ out.push({
260
+ addonId,
261
+ isDefault,
262
+ ...NOT_READABLE,
263
+ reason: error instanceof Error ? error.message : String(error),
264
+ });
265
+ }
266
+ }
267
+ return out;
113
268
  }),
114
269
  /**
115
270
  * Drop matching entries from the in-memory ring buffer. Powers
@@ -149,13 +304,27 @@ function createLogsRouter(logging) {
149
304
  .input(zod_1.z.object({
150
305
  level: LogLevelSchema.optional(),
151
306
  tags: LogTagsSchema.optional(),
307
+ /** Same text filter as `query`, applied before the push (D898). */
308
+ text: zod_1.z.string().max(200).optional(),
152
309
  }))
153
310
  .subscription(({ input }) => {
311
+ const needle = input.text?.trim().toLowerCase() ?? '';
154
312
  return (0, trpc_middleware_js_1.iterableSubscription)((push) => {
155
313
  return logging.subscribe({
156
314
  level: input.level,
157
315
  tags: input.tags,
158
316
  }, (entry) => {
317
+ if (needle.length > 0 &&
318
+ !(0, system_1.lineContainsText)({
319
+ timestamp: 0,
320
+ level: entry.level,
321
+ message: entry.message,
322
+ scope: entry.scope,
323
+ tags: entry.tags,
324
+ meta: entry.meta,
325
+ }, needle)) {
326
+ return;
327
+ }
159
328
  const stringifiedTags = entry.tags
160
329
  ? Object.fromEntries(Object.entries(entry.tags).map(([k, v]) => [
161
330
  k,
@@ -163,6 +332,7 @@ function createLogsRouter(logging) {
163
332
  ]))
164
333
  : undefined;
165
334
  push({
335
+ ...(entry.id !== undefined ? { id: entry.id } : {}),
166
336
  timestamp: entry.timestamp instanceof Date
167
337
  ? entry.timestamp.toISOString()
168
338
  : String(entry.timestamp),
@@ -2959,6 +2959,16 @@ function createCapRouter_deviceHistory(getProvider, createRemoteProxy) {
2959
2959
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2960
2960
  return p.queryNode(methodInput);
2961
2961
  }),
2962
+ queryMany: trpc_middleware_js_1.adminProcedure
2963
+ .input(types_50.deviceHistoryCapability.methods.queryMany.input.loose())
2964
+ .output(types_50.deviceHistoryCapability.methods.queryMany.output)
2965
+ .query(async ({ input, ctx }) => {
2966
+ const parsedInput = ctx.req !== undefined ? (0, types_3.withoutCallerStamp)(input) : input;
2967
+ const { nodeId, ...methodInput } = parsedInput;
2968
+ const p = resolveProvider('device-history', nodeId, () => getProvider(ctx), createRemoteProxy);
2969
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2970
+ return p.queryMany(methodInput);
2971
+ }),
2962
2972
  declareSeries: trpc_middleware_js_1.adminProcedure
2963
2973
  .input(types_50.deviceHistoryCapability.methods.declareSeries.input.loose())
2964
2974
  .output(types_50.deviceHistoryCapability.methods.declareSeries.output)
@@ -5760,14 +5770,51 @@ function createCapRouter_logDestination(getProvider, createRemoteProxy) {
5760
5770
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
5761
5771
  return p.write(methodInput);
5762
5772
  }),
5763
- query: trpc_middleware_js_1.adminProcedure
5764
- .input(types_75.logDestinationCapability.methods.query.input.loose())
5765
- .output(types_75.logDestinationCapability.methods.query.output)
5773
+ describeRead: trpc_middleware_js_1.adminProcedure
5774
+ .input(zod_1.z.object({ nodeId: zod_1.z.string().optional(), addonId: zod_1.z.string().optional() }).optional())
5775
+ .output(types_75.logDestinationCapability.methods.describeRead.output)
5766
5776
  .query(async ({ input, ctx }) => {
5777
+ const p = resolveProvider('log-destination', input?.nodeId, () => getProvider(ctx, input?.addonId), createRemoteProxy);
5778
+ const fn = p.describeRead;
5779
+ if (typeof fn !== 'function') {
5780
+ throw new server_1.TRPCError({
5781
+ code: 'NOT_IMPLEMENTED',
5782
+ message: 'Capability "log-destination" provider does not implement "describeRead"',
5783
+ });
5784
+ }
5785
+ return fn.call(p);
5786
+ }),
5787
+ read: trpc_middleware_js_1.adminProcedure
5788
+ .input(types_75.logDestinationCapability.methods.read.input.loose())
5789
+ .output(types_75.logDestinationCapability.methods.read.output)
5790
+ .query(async ({ input, ctx }) => {
5791
+ const { nodeId, addonId, ...methodInput } = input;
5792
+ const p = resolveProvider('log-destination', nodeId, () => getProvider(ctx, addonId), createRemoteProxy);
5793
+ const fn = p.read;
5794
+ if (typeof fn !== 'function') {
5795
+ throw new server_1.TRPCError({
5796
+ code: 'NOT_IMPLEMENTED',
5797
+ message: 'Capability "log-destination" provider does not implement "read"',
5798
+ });
5799
+ }
5800
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
5801
+ return fn.call(p, methodInput);
5802
+ }),
5803
+ cancelRead: trpc_middleware_js_1.adminProcedure
5804
+ .input(types_75.logDestinationCapability.methods.cancelRead.input.loose())
5805
+ .output(types_75.logDestinationCapability.methods.cancelRead.output)
5806
+ .mutation(async ({ input, ctx }) => {
5767
5807
  const { nodeId, addonId, ...methodInput } = input;
5768
5808
  const p = resolveProvider('log-destination', nodeId, () => getProvider(ctx, addonId), createRemoteProxy);
5809
+ const fn = p.cancelRead;
5810
+ if (typeof fn !== 'function') {
5811
+ throw new server_1.TRPCError({
5812
+ code: 'NOT_IMPLEMENTED',
5813
+ message: 'Capability "log-destination" provider does not implement "cancelRead"',
5814
+ });
5815
+ }
5769
5816
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
5770
- return p.query(methodInput);
5817
+ return fn.call(p, methodInput);
5771
5818
  }),
5772
5819
  });
5773
5820
  }
@@ -331,7 +331,7 @@ function buildCapabilityRouters(services) {
331
331
  repl: (0, repl_router_js_1.createReplRouter)(services.replEngine),
332
332
  systemEvents: (0, system_events_router_js_1.createSystemEventsRouter)(services.eventBus),
333
333
  capabilities: (0, capabilities_router_js_1.createCapabilitiesRouter)(services.capabilityRegistry, services.configService),
334
- logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService),
334
+ logs: (0, logs_router_js_1.createLogsRouter)(services.loggingService, services.capabilityRegistry),
335
335
  live: (0, live_events_router_js_1.createLiveEventsRouter)(services.eventBus, services.addonRegistry),
336
336
  // stream-probe — fixed core API (ffprobe wrapper), not a cap.
337
337
  streamProbe: (0, stream_probe_router_js_1.createStreamProbeRouter)(services.streamProbe),
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_PAGE_LIMIT = exports.RING_LOG_SOURCE = exports.DEFAULT_LOG_SOURCE = void 0;
4
+ exports.readLogHistory = readLogHistory;
5
+ /**
6
+ * `logs.query` — one page of history from the source the operator picked,
7
+ * merged with the hub's in-memory tail (D898).
8
+ *
9
+ * The tail is short (a couple of thousand lines) and exists for two things:
10
+ * the boot replay into destinations that register late, and the lines a file
11
+ * destination has accepted but not yet flushed. Everything older comes from
12
+ * the source. Both carry the same `id`, so the merge dedupes exactly.
13
+ */
14
+ const system_1 = require("@camstack/system");
15
+ /** The default history source: the hub's rotated files. */
16
+ exports.DEFAULT_LOG_SOURCE = 'winston-logging';
17
+ /** The in-memory tail, addressed as a source when no destination can read. */
18
+ exports.RING_LOG_SOURCE = 'ring';
19
+ exports.DEFAULT_PAGE_LIMIT = 200;
20
+ function toReadable(entry) {
21
+ return {
22
+ timestamp: entryTime(entry),
23
+ level: entry.level,
24
+ message: entry.message,
25
+ scope: entry.scope,
26
+ tags: entry.tags,
27
+ meta: entry.meta,
28
+ };
29
+ }
30
+ function entryTime(entry) {
31
+ const ts = entry.timestamp;
32
+ if (ts instanceof Date)
33
+ return ts.getTime();
34
+ if (typeof ts === 'number')
35
+ return ts;
36
+ if (typeof ts === 'string')
37
+ return Date.parse(ts);
38
+ return 0;
39
+ }
40
+ function toReadEntry(entry) {
41
+ const tags = {};
42
+ for (const [key, value] of Object.entries(entry.tags ?? {})) {
43
+ if (typeof value === 'string' || typeof value === 'number')
44
+ tags[key] = value;
45
+ }
46
+ const level = entry.level;
47
+ return {
48
+ ...(entry.id !== undefined ? { id: entry.id } : {}),
49
+ timestamp: entryTime(entry),
50
+ level,
51
+ ...(entry.scope !== undefined ? { scope: entry.scope } : {}),
52
+ message: entry.message,
53
+ ...(Object.keys(tags).length > 0 ? { tags } : {}),
54
+ ...(entry.meta !== undefined ? { meta: entry.meta } : {}),
55
+ };
56
+ }
57
+ function tailMatching(tail, filter, limit) {
58
+ const out = [];
59
+ for (const entry of tail) {
60
+ if (!(0, system_1.lineMatches)(toReadable(entry), filter))
61
+ continue;
62
+ out.push(toReadEntry(entry));
63
+ if (out.length >= limit)
64
+ break;
65
+ }
66
+ return out;
67
+ }
68
+ function entryKey(entry) {
69
+ return (entry.id ?? `${String(entry.timestamp)}|${entry.level}|${entry.scope ?? ''}|${entry.message}`);
70
+ }
71
+ /**
72
+ * The first page (no cursor) merges the tail; a cursor page is the source's
73
+ * alone — the tail only ever holds the NEWEST lines, which the first page
74
+ * already showed.
75
+ */
76
+ async function readLogHistory(args) {
77
+ const { query, tail, provider } = args;
78
+ const limit = query.limit ?? exports.DEFAULT_PAGE_LIMIT;
79
+ const filter = (0, system_1.compileLogFilter)(query);
80
+ const isFirstPage = query.before === undefined && query.after === undefined;
81
+ if (provider === null || provider.read === undefined) {
82
+ if (!isFirstPage) {
83
+ return {
84
+ entries: [],
85
+ nextBefore: null,
86
+ nextAfter: null,
87
+ scannedBytes: 0,
88
+ done: true,
89
+ source: exports.RING_LOG_SOURCE,
90
+ };
91
+ }
92
+ return {
93
+ entries: tailMatching(tail, filter, limit),
94
+ nextBefore: null,
95
+ nextAfter: null,
96
+ scannedBytes: 0,
97
+ done: true,
98
+ source: exports.RING_LOG_SOURCE,
99
+ };
100
+ }
101
+ const page = await provider.read({ ...query, limit });
102
+ if (!isFirstPage)
103
+ return { ...page, source: args.source };
104
+ // Tail lines newer than the oldest line the source returned (or all of them
105
+ // when the source returned nothing yet): the unflushed lines, and the levels
106
+ // the file destination does not keep.
107
+ const oldest = page.entries.at(-1)?.timestamp ?? Number.NEGATIVE_INFINITY;
108
+ const byKey = new Map();
109
+ for (const entry of page.entries)
110
+ byKey.set(entryKey(entry), entry);
111
+ for (const entry of tailMatching(tail, filter, limit)) {
112
+ if (entry.timestamp < oldest)
113
+ continue;
114
+ if (!byKey.has(entryKey(entry)))
115
+ byKey.set(entryKey(entry), entry);
116
+ }
117
+ const entries = [...byKey.values()].toSorted((a, b) => b.timestamp - a.timestamp);
118
+ return { ...page, entries, source: args.source };
119
+ }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LoggingService = void 0;
3
+ exports.LoggingService = exports.HUB_LOG_TAIL_ENTRIES = exports.HISTORY_DESTINATION_ADDON_ID = void 0;
4
4
  const system_1 = require("@camstack/system");
5
5
  const types_1 = require("@camstack/types");
6
6
  /** Baseline tags every hub-local logger gets, so console output never
@@ -13,6 +13,19 @@ const types_1 = require("@camstack/types");
13
13
  * forked workers via `$hub.log` carry their own `tags.pid` which
14
14
  * wins over this baseline (right-biased merge in `writeFromWorker`). */
15
15
  const HUB_BASELINE_TAGS = { agentId: 'hub', nodeId: 'hub', pid: process.pid };
16
+ /**
17
+ * The destination whose files ARE the hub's log history (D898). Once it has
18
+ * registered — and received the boot replay — the in-memory buffer only has
19
+ * to hold a short tail: the instant first page and the lines the file stream
20
+ * has accepted but not yet flushed.
21
+ */
22
+ exports.HISTORY_DESTINATION_ADDON_ID = 'winston-logging';
23
+ /**
24
+ * The tail, in entries, across every addon. ~2k lines is tens of seconds of a
25
+ * busy hub — enough for the first page and the unflushed window, ~0.6 MB.
26
+ * `eventBus.logTailSize` overrides it.
27
+ */
28
+ exports.HUB_LOG_TAIL_ENTRIES = 2000;
16
29
  class LoggingService extends system_1.LogManager {
17
30
  /**
18
31
  * Device-name cache consulted by the formatter when a log line
@@ -21,6 +34,9 @@ class LoggingService extends system_1.LogManager {
21
34
  * updated / removed events. Missing entries fall back to `#<id>`.
22
35
  */
23
36
  deviceNames = new Map();
37
+ /** Tail size applied once the history destination registers. */
38
+ tailEntries;
39
+ shrunkToTail = false;
24
40
  constructor(configService) {
25
41
  // The log buffer is now partitioned per addonId (see PartitionedLogBuffer):
26
42
  // this value caps EACH addon's bucket, not the total. A chatty addon evicts
@@ -39,12 +55,33 @@ class LoggingService extends system_1.LogManager {
39
55
  // Only entries at or below this level are ever discarded to meet the total.
40
56
  const pruneLevel = configService.get('eventBus.logBufferPruneLevel') ?? 'debug';
41
57
  super(perAddonCapacity, { maxTotalEntries, pruneLevel });
58
+ this.tailEntries = configService.get('eventBus.logTailSize') ?? exports.HUB_LOG_TAIL_ENTRIES;
42
59
  // Enriches every emitted LogEntry with `tags.deviceName` before
43
60
  // destinations / subscribers see it — works across bundled copies
44
61
  // of `@camstack/system` (addon packages) because the mutation
45
62
  // happens upstream of their formatters.
46
63
  this.setDeviceNameLookup((id) => this.deviceNames.get(id) ?? null);
47
64
  }
65
+ /**
66
+ * Adds the destination, and — the first time the HISTORY destination
67
+ * arrives — shrinks the buffer to the tail AFTER its boot replay, so the
68
+ * files start with the first boot line however long the boot was.
69
+ *
70
+ * The tail prunes `info` and below first (the lines are on disk); a live
71
+ * addon's `warn`/`error` still outlive them, and whole idle buckets go
72
+ * last, exactly as the buffer's own policy says.
73
+ */
74
+ addDestination(dest, opts = {}) {
75
+ super.addDestination(dest, opts);
76
+ if (!this.shrunkToTail && opts.addonId === exports.HISTORY_DESTINATION_ADDON_ID) {
77
+ this.shrunkToTail = true;
78
+ this.reconfigureBuffer({
79
+ perAddonCapacity: this.tailEntries,
80
+ maxTotalEntries: this.tailEntries,
81
+ pruneLevel: 'info',
82
+ });
83
+ }
84
+ }
48
85
  /** Bulk-refresh from device-manager snapshot. Replaces every entry. */
49
86
  setDeviceNames(entries) {
50
87
  this.deviceNames.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.606",
3
+ "version": "1.2.607",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -38,19 +38,19 @@
38
38
  ]
39
39
  },
40
40
  "dependencies": {
41
- "@camstack/addon-admin-ui": "1.2.442",
42
- "@camstack/addon-agent-ui": "1.2.248",
43
- "@camstack/addon-auth": "1.2.153",
44
- "@camstack/addon-decoder-nodeav": "1.2.127",
45
- "@camstack/addon-notifiers": "1.2.605",
46
- "@camstack/addon-pipeline": "1.2.605",
47
- "@camstack/addon-pipeline-orchestrator": "1.2.606",
48
- "@camstack/addon-post-analysis": "1.2.611",
49
- "@camstack/develop": "0.1.17",
50
- "@camstack/sdk": "1.2.137",
51
- "@camstack/system": "1.2.456",
52
- "@camstack/types": "1.2.387",
53
- "@camstack/ui-library": "1.2.304",
41
+ "@camstack/addon-admin-ui": "1.2.443",
42
+ "@camstack/addon-agent-ui": "1.2.249",
43
+ "@camstack/addon-auth": "1.2.154",
44
+ "@camstack/addon-decoder-nodeav": "1.2.128",
45
+ "@camstack/addon-notifiers": "1.2.606",
46
+ "@camstack/addon-pipeline": "1.2.606",
47
+ "@camstack/addon-pipeline-orchestrator": "1.2.607",
48
+ "@camstack/addon-post-analysis": "1.2.612",
49
+ "@camstack/develop": "0.1.18",
50
+ "@camstack/sdk": "1.2.138",
51
+ "@camstack/system": "1.2.457",
52
+ "@camstack/types": "1.2.388",
53
+ "@camstack/ui-library": "1.2.305",
54
54
  "@fastify/compress": "^9.0.0",
55
55
  "@fastify/cookie": "^11.0.2",
56
56
  "@fastify/cors": "^11.2.0",