Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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 assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
conversationCopyCommitFailureDiagnostic,
conversationCopyCommitFailureMessage,
} from '../server/session-revision-diagnostics.js';

describe('Session revision diagnostics', () => {
test('surfaces the error code and redacted message', () => {
const error = Object.assign(new Error('database is locked; apiKey=provider-secret'), {
code: 'SQLITE_BUSY',
});

const message = conversationCopyCommitFailureMessage(error);

assert.equal(
message,
'Session conversation copy could not be committed: SQLITE_BUSY: database is locked; apiKey=[redacted]',
);
assert.doesNotMatch(conversationCopyCommitFailureDiagnostic(error), /provider-secret/u);
});

test('bounds multibyte error messages to the operation protocol limit', () => {
const message = conversationCopyCommitFailureMessage(
new Error(`archive mismatch ${'归'.repeat(2048)}`),
);

assert.ok(Buffer.byteLength(message, 'utf8') <= 1024);
assert.match(message, /…$/u);
});

test('does not duplicate a code already present in the error message', () => {
const error = Object.assign(new Error('SQLITE_FULL: database or disk is full'), {
code: 'SQLITE_FULL',
});

assert.equal(
conversationCopyCommitFailureMessage(error),
'Session conversation copy could not be committed: SQLITE_FULL: database or disk is full',
);
});
});
11 changes: 9 additions & 2 deletions packages/runtime-host/src/server/session-revision-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ import {
agentGraphRevisionAdmissionSessionIds,
prepareAgentGraphRevisionReferences,
} from './session-revision-graph-references.js';
import {
conversationCopyCommitFailureDiagnostic,
conversationCopyCommitFailureMessage,
} from './session-revision-diagnostics.js';
import { purgeSessionSidecars } from './session-sidecar-purge.js';

type ConversationCopyKind = 'branch' | 'revision';
Expand Down Expand Up @@ -562,12 +566,15 @@ export class HostSessionRevisionCoordinator {
await this.#stores.sessionStore.readCatalogRecord(input.targetSessionId),
),
});
} catch {
} catch (error) {
console.error(
`[runtime-host] ${kind} conversation copy commit failed (${input.sourceSessionId} -> ${input.targetSessionId}): ${conversationCopyCommitFailureDiagnostic(error)}`,
);
return this.#rollbackIncompleteCopy(
kind,
input,
requestFingerprint,
'Session conversation copy could not be committed',
conversationCopyCommitFailureMessage(error),
);
}
}
Expand Down
74 changes: 74 additions & 0 deletions packages/runtime-host/src/server/session-revision-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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 { truncateUtf8 } from '@maka/core/diagnostic-log';
import { redactSecrets } from '@maka/core/redaction';

const CONVERSATION_COPY_COMMIT_FAILURE = 'Session conversation copy could not be committed';
const OPERATION_ERROR_MESSAGE_MAX_UTF8_BYTES = 1024;
const HOST_DIAGNOSTIC_MAX_UTF8_BYTES = 8 * 1024;

export function conversationCopyCommitFailureMessage(error: unknown): string {
const detail = redactSecrets(conversationCopyCommitErrorSummary(error)).trim();
if (!detail) return CONVERSATION_COPY_COMMIT_FAILURE;
return truncateUtf8(
`${CONVERSATION_COPY_COMMIT_FAILURE}: ${detail}`,
OPERATION_ERROR_MESSAGE_MAX_UTF8_BYTES,
'…',
);
}

export function conversationCopyCommitFailureDiagnostic(error: unknown): string {
let detail: string | undefined;
try {
detail = error instanceof Error ? error.stack : undefined;
} catch {
// A hostile Error subclass must not prevent rollback from running.
}
return truncateUtf8(
redactSecrets(detail || conversationCopyCommitErrorSummary(error)),
HOST_DIAGNOSTIC_MAX_UTF8_BYTES,
'\n<diagnostic truncated>',
);
}

function conversationCopyCommitErrorSummary(error: unknown): string {
try {
const candidate = isRecord(error) ? error : undefined;
const rawCode = candidate?.code;
const code =
typeof rawCode === 'string' || (typeof rawCode === 'number' && Number.isFinite(rawCode))
? String(rawCode)
: undefined;
const message =
typeof candidate?.message === 'string'
? candidate.message
: typeof error === 'string'
? error
: String(error);
if (!code || message.toLowerCase().includes(code.toLowerCase())) return message;
return message ? `${code}: ${message}` : code;
} catch {
return 'Unknown error';
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}