blob: 04c854a44c0370b6dee1d63137c9b2160981f08f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { requireCount, requireEntityId, requireExactRecord, requireShapedRecord } from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
/**
* Sessions measured by one `storage.usage.sessions.query`. Each Session costs
* several indexed range scans on the Host, so a request stays small and a
* Client pages through a longer list one request at a time.
*/
export const STORAGE_USAGE_SESSION_MAX_ITEMS = 25;
/**
* State Root parts the Host can size without scanning database rows.
*
* `database` is the operational SQLite file set. `artifacts` sums recorded
* artifact sizes. `context_offload` is its SQLite file set, which holds inline
* blobs, plus the managed value files beside it. `memory` is the long-term
* memory SQLite file set. Worktrees are counted separately and never sized.
*/
export const STORAGE_USAGE_KINDS = ['database', 'artifacts', 'context_offload', 'memory'] as const;
export type StorageUsageKind = (typeof STORAGE_USAGE_KINDS)[number];
export interface StorageUsageTotal {
readonly kind: StorageUsageKind;
readonly bytes: number;
/** False when the figure comes from recorded metadata or may overlap another figure. */
readonly exact: boolean;
}
export type StorageUsageQueryInput = Record<string, never>;
export interface StorageUsageQueryResult {
/** Host clock, epoch milliseconds. */
readonly measuredAt: number;
readonly totals: readonly StorageUsageTotal[];
/** Free pages in the operational database that compaction could release. */
readonly reclaimableBytes: number;
readonly worktreeCount: number;
}
export interface SessionStorageUsage {
readonly sessionId: string;
/** Logical bytes stored for the Session, excluding page and index overhead. */
readonly bytes: {
readonly transcript: number;
readonly runtime: number;
/** Recorded artifact sizes. */
readonly artifacts: number;
/**
* Logical context-offload bytes referenced by the Session. Blobs are shared
* across Sessions, so this is not what deleting the Session would free.
* Absent when the Host has no context-offload Store.
*/
readonly context?: number;
};
/** Worktrees bound to the Session or to its direct Subagent children. */
readonly worktreeCount: number;
}
export interface StorageSessionUsageQueryInput {
readonly sessionIds: readonly string[];
}
export interface StorageSessionUsageQueryResult {
/**
* The requested Sessions that exist, in request order. A Session the Host
* does not hold is omitted rather than reported as empty.
*/
readonly sessions: readonly SessionStorageUsage[];
}
const STORAGE_USAGE_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'persistence_failed',
'internal_failure',
] as const;
export const STORAGE_USAGE_OPERATION_SPECS = {
'storage.usage.query': defineOperation<
StorageUsageQueryInput,
StorageUsageQueryResult,
(typeof STORAGE_USAGE_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: STORAGE_USAGE_ERRORS,
decodeInput: decodeStorageUsageQueryInput,
decodeOutput: decodeStorageUsageQueryResult,
}),
'storage.usage.sessions.query': defineOperation<
StorageSessionUsageQueryInput,
StorageSessionUsageQueryResult,
(typeof STORAGE_USAGE_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: STORAGE_USAGE_ERRORS,
decodeInput: decodeStorageSessionUsageQueryInput,
decodeOutput: decodeStorageSessionUsageQueryResult,
assertOutputForInput: (input, output) => {
// A subsequence of the request: each id at most once, in request order.
let next = 0;
for (const session of output.sessions) {
while (next < input.sessionIds.length && input.sessionIds[next] !== session.sessionId) {
next += 1;
}
if (next === input.sessionIds.length) {
throw invalidProtocolFrame('Storage usage Sessions do not match the request');
}
next += 1;
}
},
}),
} as const;
export function decodeStorageUsageQueryInput(value: unknown): StorageUsageQueryInput {
requireExactRecord(value, 'storage usage input', []);
return {};
}
export function decodeStorageUsageQueryResult(value: unknown): StorageUsageQueryResult {
const result = requireExactRecord(value, 'storage usage result', [
'measuredAt',
'totals',
'reclaimableBytes',
'worktreeCount',
]);
if (!Array.isArray(result.totals) || result.totals.length > STORAGE_USAGE_KINDS.length) {
throw invalidProtocolFrame('Invalid storage usage totals');
}
const totals = result.totals.map(decodeTotal);
if (new Set(totals.map((total) => total.kind)).size !== totals.length) {
throw invalidProtocolFrame('Duplicate storage usage kind');
}
return {
measuredAt: requireCount(result.measuredAt, 'storage usage measuredAt'),
totals,
reclaimableBytes: requireCount(result.reclaimableBytes, 'storage usage reclaimableBytes'),
worktreeCount: requireCount(result.worktreeCount, 'storage usage worktreeCount'),
};
}
export function decodeStorageSessionUsageQueryInput(value: unknown): StorageSessionUsageQueryInput {
const input = requireExactRecord(value, 'storage Session usage input', ['sessionIds']);
if (
!Array.isArray(input.sessionIds) ||
input.sessionIds.length === 0 ||
input.sessionIds.length > STORAGE_USAGE_SESSION_MAX_ITEMS
) {
throw invalidProtocolFrame('Invalid storage usage sessionIds');
}
const sessionIds = input.sessionIds.map((sessionId) => requireEntityId(sessionId, 'sessionId'));
if (new Set(sessionIds).size !== sessionIds.length) {
throw invalidProtocolFrame('Duplicate storage usage sessionId');
}
return { sessionIds };
}
export function decodeStorageSessionUsageQueryResult(
value: unknown,
): StorageSessionUsageQueryResult {
const result = requireExactRecord(value, 'storage Session usage result', ['sessions']);
if (!Array.isArray(result.sessions) || result.sessions.length > STORAGE_USAGE_SESSION_MAX_ITEMS) {
throw invalidProtocolFrame('Invalid storage usage Sessions');
}
const sessions = result.sessions.map(decodeSessionUsage);
if (new Set(sessions.map((session) => session.sessionId)).size !== sessions.length) {
throw invalidProtocolFrame('Duplicate storage usage Session');
}
return { sessions };
}
function decodeTotal(value: unknown): StorageUsageTotal {
const total = requireExactRecord(value, 'storage usage total', ['kind', 'bytes', 'exact']);
if (!STORAGE_USAGE_KINDS.includes(total.kind as StorageUsageKind)) {
throw invalidProtocolFrame('Invalid storage usage kind');
}
if (typeof total.exact !== 'boolean') {
throw invalidProtocolFrame('Invalid storage usage exactness');
}
return {
kind: total.kind as StorageUsageKind,
bytes: requireCount(total.bytes, 'storage usage bytes'),
exact: total.exact,
};
}
function decodeSessionUsage(value: unknown): SessionStorageUsage {
const session = requireExactRecord(value, 'storage usage Session', [
'sessionId',
'bytes',
'worktreeCount',
]);
const bytes = requireShapedRecord(
session.bytes,
'storage usage Session bytes',
['transcript', 'runtime', 'artifacts'],
['context'],
);
return {
sessionId: requireEntityId(session.sessionId, 'sessionId'),
bytes: {
transcript: requireCount(bytes.transcript, 'storage usage transcript bytes'),
runtime: requireCount(bytes.runtime, 'storage usage runtime bytes'),
artifacts: requireCount(bytes.artifacts, 'storage usage artifact bytes'),
...(bytes.context === undefined
? {}
: { context: requireCount(bytes.context, 'storage usage context bytes') }),
},
worktreeCount: requireCount(session.worktreeCount, 'storage usage worktreeCount'),
};
}