@neurocode-ai/server 1.18.8

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.
@@ -0,0 +1,32 @@
1
+ import { Catalog } from "@neurocode-ai/core/catalog"
2
+ import { Effect } from "effect"
3
+ import { HttpApiBuilder } from "effect/unstable/httpapi"
4
+ import { Api } from "../api"
5
+ import { ProviderNotFoundError } from "@neurocode-ai/protocol/errors"
6
+ import { response } from "../location"
7
+
8
+ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
9
+ Effect.gen(function* () {
10
+ return handlers
11
+ .handle(
12
+ "provider.list",
13
+ Effect.fn(function* () {
14
+ const catalog = yield* Catalog.Service
15
+ return yield* response(catalog.provider.available())
16
+ }),
17
+ )
18
+ .handle(
19
+ "provider.get",
20
+ Effect.fn(function* (ctx) {
21
+ const catalog = yield* Catalog.Service
22
+ const provider = yield* catalog.provider.get(ctx.params.providerID)
23
+ if (!provider)
24
+ return yield* new ProviderNotFoundError({
25
+ providerID: ctx.params.providerID,
26
+ message: `Provider not found: ${ctx.params.providerID}`,
27
+ })
28
+ return yield* response(Effect.succeed(provider))
29
+ }),
30
+ )
31
+ }),
32
+ )
@@ -0,0 +1,223 @@
1
+ import { Pty } from "@neurocode-ai/core/pty"
2
+ import { PtyProtocol } from "@neurocode-ai/core/pty/protocol"
3
+ import { PtyTicket } from "@neurocode-ai/core/pty/ticket"
4
+ import { Location } from "@neurocode-ai/core/location"
5
+ import { Effect, Queue } from "effect"
6
+ import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
7
+ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
8
+ import * as Socket from "effect/unstable/socket/Socket"
9
+ import { Api } from "../api"
10
+ import { CorsConfig, isAllowedRequestOrigin } from "../cors"
11
+ import { ForbiddenError, PtyNotFoundError } from "@neurocode-ai/protocol/errors"
12
+ import {
13
+ PTY_CONNECT_TICKET_QUERY,
14
+ PTY_CONNECT_TOKEN_HEADER,
15
+ PTY_CONNECT_TOKEN_HEADER_VALUE,
16
+ } from "@neurocode-ai/protocol/groups/pty"
17
+ import { response } from "../location"
18
+ import { PtyEnvironment } from "../pty-environment"
19
+
20
+ const ticketScope = Effect.gen(function* () {
21
+ const location = yield* Location.Service
22
+ return { directory: location.directory as string, workspaceID: location.workspaceID }
23
+ })
24
+
25
+ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
26
+ Effect.gen(function* () {
27
+ const tickets = yield* PtyTicket.Service
28
+ const cors = yield* CorsConfig
29
+ const environment = yield* PtyEnvironment.Service
30
+
31
+ return handlers
32
+ .handle(
33
+ "pty.list",
34
+ Effect.fn(function* () {
35
+ return yield* response((yield* Pty.Service).list())
36
+ }),
37
+ )
38
+ .handle(
39
+ "pty.create",
40
+ Effect.fn(function* (ctx) {
41
+ const pty = yield* Pty.Service
42
+ const location = yield* Location.Service
43
+ const cwd = ctx.payload.cwd || location.directory
44
+ return yield* response(
45
+ pty.create({
46
+ ...ctx.payload,
47
+ args: ctx.payload.args ? [...ctx.payload.args] : undefined,
48
+ cwd,
49
+ env: {
50
+ ...ctx.payload.env,
51
+ ...(yield* environment.get({ directory: location.directory, cwd })),
52
+ },
53
+ }),
54
+ )
55
+ }),
56
+ )
57
+ .handle(
58
+ "pty.get",
59
+ Effect.fn(function* (ctx) {
60
+ const pty = yield* Pty.Service
61
+ return yield* response(
62
+ pty.get(ctx.params.ptyID).pipe(
63
+ Effect.catchTag(
64
+ "Pty.NotFoundError",
65
+ () =>
66
+ new PtyNotFoundError({
67
+ ptyID: ctx.params.ptyID,
68
+ message: `PTY session not found: ${ctx.params.ptyID}`,
69
+ }),
70
+ ),
71
+ ),
72
+ )
73
+ }),
74
+ )
75
+ .handle(
76
+ "pty.update",
77
+ Effect.fn(function* (ctx) {
78
+ const pty = yield* Pty.Service
79
+ return yield* response(
80
+ pty
81
+ .update(ctx.params.ptyID, {
82
+ ...ctx.payload,
83
+ size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
84
+ })
85
+ .pipe(
86
+ Effect.catchTag(
87
+ "Pty.NotFoundError",
88
+ () =>
89
+ new PtyNotFoundError({
90
+ ptyID: ctx.params.ptyID,
91
+ message: `PTY session not found: ${ctx.params.ptyID}`,
92
+ }),
93
+ ),
94
+ ),
95
+ )
96
+ }),
97
+ )
98
+ .handle(
99
+ "pty.remove",
100
+ Effect.fn(function* (ctx) {
101
+ const pty = yield* Pty.Service
102
+ yield* pty.remove(ctx.params.ptyID).pipe(
103
+ Effect.catchTag(
104
+ "Pty.NotFoundError",
105
+ () =>
106
+ new PtyNotFoundError({
107
+ ptyID: ctx.params.ptyID,
108
+ message: `PTY session not found: ${ctx.params.ptyID}`,
109
+ }),
110
+ ),
111
+ )
112
+ return HttpApiSchema.NoContent.make()
113
+ }),
114
+ )
115
+ .handle(
116
+ "pty.connectToken",
117
+ Effect.fn(function* (ctx) {
118
+ const request = yield* HttpServerRequest.HttpServerRequest
119
+ // The custom header forces a CORS preflight, so cross-origin browser pages cannot
120
+ // mint tickets without passing the server's origin policy.
121
+ if (
122
+ request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE ||
123
+ !isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors)
124
+ )
125
+ return yield* new ForbiddenError({ message: "Invalid PTY connect token request" })
126
+ const pty = yield* Pty.Service
127
+ yield* pty.get(ctx.params.ptyID).pipe(
128
+ Effect.catchTag(
129
+ "Pty.NotFoundError",
130
+ () =>
131
+ new PtyNotFoundError({
132
+ ptyID: ctx.params.ptyID,
133
+ message: `PTY session not found: ${ctx.params.ptyID}`,
134
+ }),
135
+ ),
136
+ )
137
+ return yield* response(tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) }))
138
+ }),
139
+ )
140
+ .handleRaw(
141
+ "pty.connect",
142
+ Effect.fn("PtyHandler.connect")(function* (ctx) {
143
+ const pty = yield* Pty.Service
144
+ const exists = yield* pty.get(ctx.params.ptyID).pipe(
145
+ Effect.as(true),
146
+ Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
147
+ )
148
+ if (!exists) return HttpServerResponse.empty({ status: 404 })
149
+
150
+ const url = new URL(ctx.request.url, "http://localhost")
151
+ const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
152
+ if (ticket) {
153
+ const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors)
154
+ ? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) })
155
+ : false
156
+ if (!valid) return HttpServerResponse.empty({ status: 403 })
157
+ }
158
+ const parsedCursor = url.searchParams.get("cursor")
159
+ const cursorNumber = parsedCursor === null ? undefined : Number(parsedCursor)
160
+ const cursor =
161
+ cursorNumber !== undefined && Number.isSafeInteger(cursorNumber) && cursorNumber >= -1
162
+ ? cursorNumber
163
+ : undefined
164
+
165
+ const socket = yield* Effect.orDie(ctx.request.upgrade)
166
+ const write = yield* socket.writer
167
+ const closeAccepted = (event: Socket.CloseEvent) =>
168
+ socket
169
+ .runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
170
+ .pipe(
171
+ Effect.timeout("1 second"),
172
+ Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
173
+ Effect.catch(() => Effect.void),
174
+ )
175
+
176
+ // Outbound frames flow through one queue drained by a single writer so replay, live
177
+ // output, and the close frame keep their order.
178
+ // TODO: Integrate graceful-shutdown socket tracking before clients migrate to this route.
179
+ const outbox = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
180
+ const attachment = yield* pty
181
+ .attach(ctx.params.ptyID, {
182
+ cursor,
183
+ onData: (chunk) => Queue.offerUnsafe(outbox, chunk),
184
+ onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)),
185
+ })
186
+ .pipe(
187
+ Effect.catchTags({
188
+ "Pty.NotFoundError": () =>
189
+ closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
190
+ "Pty.ExitedError": () =>
191
+ closeAccepted(new Socket.CloseEvent(4404, "session exited")).pipe(Effect.as(undefined)),
192
+ }),
193
+ )
194
+ if (!attachment) return HttpServerResponse.empty()
195
+
196
+ for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk)
197
+ Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor))
198
+ attachment.activate()
199
+
200
+ const drain = Effect.gen(function* () {
201
+ while (true) {
202
+ const item = yield* Queue.take(outbox)
203
+ yield* write(item)
204
+ if (item instanceof Socket.CloseEvent) return
205
+ }
206
+ })
207
+
208
+ yield* Effect.race(
209
+ drain,
210
+ socket.runRaw((message) => {
211
+ const decoded = PtyProtocol.decodeInput(message)
212
+ if (decoded !== undefined) attachment.write(decoded)
213
+ }),
214
+ ).pipe(
215
+ Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
216
+ Effect.ensuring(Effect.sync(() => attachment.detach())),
217
+ Effect.orDie,
218
+ )
219
+ return HttpServerResponse.empty()
220
+ }),
221
+ )
222
+ }),
223
+ )
@@ -0,0 +1,62 @@
1
+ import { QuestionV2 } from "@neurocode-ai/core/question"
2
+ import { Effect } from "effect"
3
+ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
4
+ import { Api } from "../api"
5
+ import { QuestionNotFoundError } from "@neurocode-ai/protocol/errors"
6
+ import { response } from "../location"
7
+
8
+ function missingRequest(id: QuestionV2.ID) {
9
+ return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
10
+ }
11
+
12
+ export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
13
+ Effect.gen(function* () {
14
+ const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
15
+ sessionID: QuestionV2.Request["sessionID"],
16
+ requestID: QuestionV2.ID,
17
+ use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
18
+ ) {
19
+ const question = yield* QuestionV2.Service
20
+ const request = (yield* question.list()).find((request) => request.id === requestID)
21
+ if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
22
+ return yield* use(question)
23
+ })
24
+
25
+ return handlers
26
+ .handle(
27
+ "question.request.list",
28
+ Effect.fn(function* () {
29
+ return yield* response((yield* QuestionV2.Service).list())
30
+ }),
31
+ )
32
+ .handle(
33
+ "session.question.list",
34
+ Effect.fn(function* (ctx) {
35
+ const requests = yield* (yield* QuestionV2.Service).list()
36
+ return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
37
+ }),
38
+ )
39
+ .handle(
40
+ "session.question.reply",
41
+ Effect.fn(function* (ctx) {
42
+ yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
43
+ question
44
+ .reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
45
+ .pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
46
+ )
47
+ return HttpApiSchema.NoContent.make()
48
+ }),
49
+ )
50
+ .handle(
51
+ "session.question.reject",
52
+ Effect.fn(function* (ctx) {
53
+ yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
54
+ question
55
+ .reject(ctx.params.requestID)
56
+ .pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
57
+ )
58
+ return HttpApiSchema.NoContent.make()
59
+ }),
60
+ )
61
+ }),
62
+ )
@@ -0,0 +1,8 @@
1
+ import { Reference } from "@neurocode-ai/core/reference"
2
+ import { HttpApiBuilder } from "effect/unstable/httpapi"
3
+ import { Api } from "../api"
4
+ import { response } from "../location"
5
+
6
+ export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
7
+ handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),
8
+ )