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
79 changes: 79 additions & 0 deletions packages/kap-server/src/protocol/rest-feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* POST /v1/feedback
* POST /v1/feedback/upload_url
* POST /v1/feedback/upload_complete
*
* Wire shapes for the user feedback endpoint. The wire uses snake_case (REST
* convention in this repo). Submissions are forwarded to the managed
* collection backend with the managed provider's OAuth token (see
* `src/services/feedback/`); `content` / `contact` / `info` / `session_id`
* match the backend's metadata contract, and the attachment routes mirror
* its presigned-upload flow verbatim.
*/

import { z } from 'zod';

/** Feedback category: bug report, feature request, or anything else. */
export const feedbackTypeSchema = z.enum(['bug', 'feature', 'other']);
export type FeedbackType = z.infer<typeof feedbackTypeSchema>;

/** Attached diagnostics: nothing extra, local logs, or local logs plus the codebase. */
export const feedbackDiagnosticsSchema = z.enum(['none', 'logs', 'logs_and_codebase']);
export type FeedbackDiagnostics = z.infer<typeof feedbackDiagnosticsSchema>;

const feedbackInfoReservedKeys = ['type', 'title', 'diagnostics', 'agent_id'] as const;
const feedbackInfoSchema = z.record(z.string(), z.unknown()).superRefine((info, ctx) => {
for (const key of feedbackInfoReservedKeys) {
if (Object.hasOwn(info, key)) {
ctx.addIssue({
code: 'custom',
message: `${key} is reserved; use the top-level field instead`,
path: [key],
});
}
}
});

export const feedbackSubmitBodySchema = z.object({
content: z.string().min(1).max(20000),
session_id: z.string().min(1).max(256),
type: feedbackTypeSchema.optional(),
title: z.string().min(1).max(256).optional(),
contact: z.string().min(1).max(256).optional(),
diagnostics: feedbackDiagnosticsSchema.optional(),
agent_id: z.string().min(1).max(256).optional(),
info: feedbackInfoSchema.optional(),
});
export type FeedbackSubmitBody = z.infer<typeof feedbackSubmitBodySchema>;

export const feedbackSubmitResponseSchema = z.object({
feedback_id: z.number().int(),
});
export type FeedbackSubmitResponse = z.infer<typeof feedbackSubmitResponseSchema>;

export const feedbackUploadUrlBodySchema = z.object({
feedback_id: z.number().int(),
file_name: z.string().min(1).max(256),
file_size: z.number().int().nonnegative(),
file_hash: z.string().min(1).max(128),
});
export type FeedbackUploadUrlBody = z.infer<typeof feedbackUploadUrlBodySchema>;

export const feedbackUploadPartSchema = z.object({
part_number: z.number().int(),
url: z.string(),
method: z.string(),
size: z.number().int(),
});

export const feedbackUploadUrlResponseSchema = z.object({
upload_id: z.number().int(),
parts: z.array(feedbackUploadPartSchema),
});
export type FeedbackUploadUrlResponse = z.infer<typeof feedbackUploadUrlResponseSchema>;

export const feedbackUploadCompleteBodySchema = z.object({
upload_id: z.number().int(),
parts: z.array(z.object({ part_number: z.number().int(), etag: z.string().min(1) })).min(1),
});
export type FeedbackUploadCompleteBody = z.infer<typeof feedbackUploadCompleteBodySchema>;
144 changes: 144 additions & 0 deletions packages/kap-server/src/routes/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* `/feedback` route handlers — user feedback submission and attachment uploads.
*
* Thin adapters over `IFeedbackService` (see `src/services/feedback/`): the
* validated wire body is forwarded to the managed collection backend with the
* managed provider's OAuth token. `not_signed_in` maps to `40111`, backend
* failures to `50001`.
*
* POST /feedback body: FeedbackSubmitBody data: FeedbackSubmitResponse
* POST /feedback/upload_url body: FeedbackUploadUrlBody data: FeedbackUploadUrlResponse
* POST /feedback/upload_complete body: FeedbackUploadCompleteBody data: null
*/

import { z } from 'zod';

import { errEnvelope, okEnvelope } from '../envelope';
import { requestLog } from '../lib/requestLog';
import { defineRoute } from '../middleware/defineRoute';
import { ErrorCode } from '../protocol/error-codes';
import {
feedbackSubmitBodySchema,
feedbackSubmitResponseSchema,
feedbackUploadCompleteBodySchema,
feedbackUploadUrlBodySchema,
feedbackUploadUrlResponseSchema,
} from '../protocol/rest-feedback';
import { FeedbackError, type IFeedbackService } from '../services/feedback/feedback';

interface FeedbackRouteHost {
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}

function sendError(req: { id: string }, reply: { send(payload: unknown): unknown }, error: unknown): void {
if (error instanceof FeedbackError) {
const code = error.reason === 'not_signed_in' ? ErrorCode.AUTH_TOKEN_MISSING : ErrorCode.INTERNAL_ERROR;
reply.send(errEnvelope(code, error.message, req.id, error.stack));
return;
}
requestLog(req)?.error({ err: error }, 'feedback request failed');
reply.send(
errEnvelope(
ErrorCode.INTERNAL_ERROR,
error instanceof Error ? error.message : String(error),
req.id,
error instanceof Error ? error.stack : undefined,
),
);
}

export function registerFeedbackRoutes(app: FeedbackRouteHost, feedback: IFeedbackService): void {
const submitRoute = defineRoute(
{
method: 'POST',
path: '/feedback',
body: feedbackSubmitBodySchema,
success: { data: feedbackSubmitResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: {},
[ErrorCode.AUTH_TOKEN_MISSING]: {},
[ErrorCode.INTERNAL_ERROR]: {},
},
description: 'Submit user feedback; forwarded to the managed collection backend',
tags: ['feedback'],
},
async (req, reply) => {
try {
const { feedbackId } = await feedback.submit(req.body);
reply.send(okEnvelope({ feedback_id: feedbackId }, req.id));
} catch (error) {
sendError(req, reply, error);
}
},
);
app.post(
submitRoute.path,
submitRoute.options,
submitRoute.handler as Parameters<FeedbackRouteHost['post']>[2],
);

const uploadUrlRoute = defineRoute(
{
method: 'POST',
path: '/feedback/upload_url',
body: feedbackUploadUrlBodySchema,
success: { data: feedbackUploadUrlResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: {},
[ErrorCode.AUTH_TOKEN_MISSING]: {},
[ErrorCode.INTERNAL_ERROR]: {},
},
description: 'Create presigned upload URLs for a feedback attachment',
tags: ['feedback'],
},
async (req, reply) => {
try {
const result = await feedback.createUploadUrl(req.body);
reply.send(okEnvelope(result, req.id));
} catch (error) {
sendError(req, reply, error);
}
},
);
app.post(
uploadUrlRoute.path,
uploadUrlRoute.options,
uploadUrlRoute.handler as Parameters<FeedbackRouteHost['post']>[2],
);

const uploadCompleteRoute = defineRoute(
{
method: 'POST',
path: '/feedback/upload_complete',
body: feedbackUploadCompleteBodySchema,
success: { data: z.null() },
errors: {
[ErrorCode.VALIDATION_FAILED]: {},
[ErrorCode.AUTH_TOKEN_MISSING]: {},
[ErrorCode.INTERNAL_ERROR]: {},
},
description: 'Mark a feedback attachment upload as complete',
tags: ['feedback'],
},
async (req, reply) => {
try {
await feedback.completeUpload(req.body);
reply.send(okEnvelope(null, req.id));
} catch (error) {
sendError(req, reply, error);
}
},
);
app.post(
uploadCompleteRoute.path,
uploadCompleteRoute.options,
uploadCompleteRoute.handler as Parameters<FeedbackRouteHost['post']>[2],
);
}
7 changes: 7 additions & 0 deletions packages/kap-server/src/routes/registerApiV1Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import { registerApprovalsRoutes } from './approvals';
import { registerAuthRoute } from './auth';
import { registerConfigRoutes } from './config';
import { registerConnectionsRoutes } from './connections';
import { registerFeedbackRoutes } from './feedback';
import { registerFilesRoutes } from './files';
import { registerFsRoutes } from './fs';
import { registerGuiStoreRoutes } from './guiStore';
import { registerMessagesRoutes } from './messages';
import type { IFeedbackService } from '../services/feedback/feedback';
import type { IGuiStoreService } from '../services/guiStore/guiStore';
import type { ISnapshotReader } from '../services/snapshot';
import { registerDebugRoutes } from '../transport/registerDebugRoutes';
Expand Down Expand Up @@ -66,6 +68,7 @@ export interface RegisterApiV1RoutesOptions {
readonly enableShutdown?: boolean;
readonly enableTerminals?: boolean;
readonly guiStore: IGuiStoreService;
readonly feedback: IFeedbackService;
readonly onShutdown: () => void;
readonly connectionRegistry: IConnectionRegistry;
readonly broadcaster: SessionEventBroadcaster;
Expand Down Expand Up @@ -147,6 +150,10 @@ export async function registerApiV1Routes(
registerFilesRoutes(apiV1 as unknown as Parameters<typeof registerFilesRoutes>[0], core);
registerFsRoutes(apiV1 as unknown as Parameters<typeof registerFsRoutes>[0], core);
registerGuiStoreRoutes(apiV1 as unknown as Parameters<typeof registerGuiStoreRoutes>[0], opts.guiStore);
registerFeedbackRoutes(
apiV1 as unknown as Parameters<typeof registerFeedbackRoutes>[0],
opts.feedback,
);
registerToolsRoutes(apiV1 as unknown as Parameters<typeof registerToolsRoutes>[0], core);
if (opts.enableTerminals !== false) {
registerTerminalsRoutes(
Expand Down
67 changes: 67 additions & 0 deletions packages/kap-server/src/services/feedback/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createDecorator } from '@moonshot-ai/agent-core-v2';

import type { FeedbackDiagnostics, FeedbackType } from '../../protocol/rest-feedback';

/**
* `IFeedbackService` — forwards user feedback to the managed collection
* backend with the managed provider's OAuth token, mirroring the CLI's
* `/feedback` flow. The submit call stamps the host version, server OS, and
* default model server-side; attachment uploads use the backend's
* presigned-URL flow (`upload_url` / `upload_complete`).
*/
export interface FeedbackEntry {
readonly content: string;
readonly session_id: string;
readonly contact?: string;
readonly type?: FeedbackType;
readonly title?: string;
readonly diagnostics?: FeedbackDiagnostics;
readonly agent_id?: string;
readonly info?: Record<string, unknown>;
}

export interface FeedbackUploadUrlInput {
readonly feedback_id: number;
readonly file_name: string;
readonly file_size: number;
readonly file_hash: string;
}

export interface FeedbackUploadPart {
readonly part_number: number;
readonly url: string;
readonly method: string;
readonly size: number;
}

export interface FeedbackUploadUrlResult {
readonly upload_id: number;
readonly parts: readonly FeedbackUploadPart[];
}

export interface FeedbackUploadCompleteInput {
readonly upload_id: number;
readonly parts: readonly { readonly part_number: number; readonly etag: string }[];
}

export type FeedbackErrorReason = 'not_signed_in' | 'backend_error';

export class FeedbackError extends Error {
constructor(
readonly reason: FeedbackErrorReason,
message: string,
readonly status?: number,
) {
super(message);
this.name = 'FeedbackError';
}
}

export interface IFeedbackService {
readonly _serviceBrand: undefined;
submit(entry: FeedbackEntry): Promise<{ feedbackId: number }>;
createUploadUrl(input: FeedbackUploadUrlInput): Promise<FeedbackUploadUrlResult>;
completeUpload(input: FeedbackUploadCompleteInput): Promise<void>;
}

export const IFeedbackService = createDecorator<IFeedbackService>('feedbackService');
Loading
Loading