create-loopengine 0.1.42 → 0.1.44
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.
- package/package.json +1 -1
- package/template/adapters/http.ts +162 -1
package/package.json
CHANGED
|
@@ -2038,6 +2038,108 @@ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: Turn
|
|
|
2038
2038
|
)
|
|
2039
2039
|
}
|
|
2040
2040
|
|
|
2041
|
+
// A generated image/archive's own signed cloud-storage URL is a long,
|
|
2042
|
+
// high-entropy string (a GCS V4 Signature can run ~300 base64
|
|
2043
|
+
// characters) that an agent's own reply has to reproduce character-for-
|
|
2044
|
+
// character to embed as a markdown image/link — expensive in output
|
|
2045
|
+
// tokens, slow to generate, and one flipped character anywhere in it
|
|
2046
|
+
// breaks the whole thing. This route lets a tool return a short, stable
|
|
2047
|
+
// object reference instead — bucket + object name, both human-readable,
|
|
2048
|
+
// not random — and defer the actual signing to request time, right
|
|
2049
|
+
// here, freshly, on every click: shorter for the model to write,
|
|
2050
|
+
// nothing random left to transcribe wrong, and a much shorter
|
|
2051
|
+
// signed-URL lifetime than an ability calling getSignedUrl itself at
|
|
2052
|
+
// generation time ever needed (this one only has to stay valid for as
|
|
2053
|
+
// long as the click that requested it takes to resolve, not however
|
|
2054
|
+
// long a chat reply might sit around unopened).
|
|
2055
|
+
//
|
|
2056
|
+
// Deliberately generic, not tied to any one ability — any ability using
|
|
2057
|
+
// GCS storage can point a tool's own returned URL here instead of
|
|
2058
|
+
// signing itself, as long as it authenticates the same way
|
|
2059
|
+
// lp-product-ad-images'/lp-file-archiver's own buildGcsStorageClient
|
|
2060
|
+
// already do (GOOGLE_APPLICATION_CREDENTIALS_JSON, falling back to
|
|
2061
|
+
// Application Default Credentials) — this is that same logic,
|
|
2062
|
+
// centralized, rather than every GCS-backed ability duplicating it
|
|
2063
|
+
// (which they already do today for the *upload* side; this just does
|
|
2064
|
+
// the same for signing).
|
|
2065
|
+
//
|
|
2066
|
+
// Sits behind the same isAuthorized() Basic Auth gate every other route
|
|
2067
|
+
// on this server already requires (checked once, unconditionally,
|
|
2068
|
+
// before any routing below even runs) — not a separately public
|
|
2069
|
+
// endpoint. A browser that already loaded /playground has that same
|
|
2070
|
+
// origin's Basic Auth credentials cached, so a plain
|
|
2071
|
+
// <img src="/gcs-redirect?..."> or download link both just work with no
|
|
2072
|
+
// extra wiring; anything without those credentials gets the same 401
|
|
2073
|
+
// every other route already gives it.
|
|
2074
|
+
async function handleGcsRedirect(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
2075
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
2076
|
+
const bucket = url.searchParams.get('bucket')
|
|
2077
|
+
const object = url.searchParams.get('object')
|
|
2078
|
+
const disposition = url.searchParams.get('disposition')
|
|
2079
|
+
const filename = url.searchParams.get('filename')
|
|
2080
|
+
|
|
2081
|
+
if (!bucket || !object) {
|
|
2082
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'gcs-redirect requires both bucket and object query params' }))
|
|
2083
|
+
return
|
|
2084
|
+
}
|
|
2085
|
+
if (disposition && disposition !== 'inline' && disposition !== 'attachment') {
|
|
2086
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `disposition must be "inline" or "attachment" — got "${disposition}"` }))
|
|
2087
|
+
return
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
// Imported by a variable, not a string literal, so tsc treats this as
|
|
2091
|
+
// Promise<any> instead of trying to resolve @google-cloud/storage's
|
|
2092
|
+
// own types at compile time — same reasoning
|
|
2093
|
+
// lp-product-ad-images'/lp-file-archiver's own saveImage/saveArchive
|
|
2094
|
+
// already use, for the same reason: installing it is only required at
|
|
2095
|
+
// runtime for a deployment that actually uses this route, not every
|
|
2096
|
+
// scaffolded project.
|
|
2097
|
+
const gcsModuleName = '@google-cloud/storage'
|
|
2098
|
+
let gcs: any
|
|
2099
|
+
try {
|
|
2100
|
+
gcs = await import(gcsModuleName)
|
|
2101
|
+
} catch {
|
|
2102
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(
|
|
2103
|
+
JSON.stringify({ error: 'gcs-redirect requires the @google-cloud/storage package — npm install @google-cloud/storage in your own project.' }),
|
|
2104
|
+
)
|
|
2105
|
+
return
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
let client: any
|
|
2109
|
+
const credentialsJson = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON
|
|
2110
|
+
if (credentialsJson) {
|
|
2111
|
+
let credentials: { project_id?: string }
|
|
2112
|
+
try {
|
|
2113
|
+
credentials = JSON.parse(credentialsJson)
|
|
2114
|
+
} catch {
|
|
2115
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(
|
|
2116
|
+
JSON.stringify({
|
|
2117
|
+
error: 'GOOGLE_APPLICATION_CREDENTIALS_JSON is not valid JSON — paste the entire contents of the downloaded service-account key file, unedited.',
|
|
2118
|
+
}),
|
|
2119
|
+
)
|
|
2120
|
+
return
|
|
2121
|
+
}
|
|
2122
|
+
client = new gcs.Storage({ credentials, projectId: credentials.project_id })
|
|
2123
|
+
} else {
|
|
2124
|
+
client = new gcs.Storage()
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
try {
|
|
2128
|
+
const file = client.bucket(bucket).file(object)
|
|
2129
|
+
const expires = Date.now() + 5 * 60 * 1000
|
|
2130
|
+
const signOptions: Record<string, unknown> = { action: 'read', expires }
|
|
2131
|
+
if (disposition === 'attachment') {
|
|
2132
|
+
signOptions.responseDisposition = `attachment; filename="${filename || object.split('/').pop()}"`
|
|
2133
|
+
}
|
|
2134
|
+
const [signedUrl] = await file.getSignedUrl(signOptions)
|
|
2135
|
+
res.writeHead(302, { location: signedUrl }).end()
|
|
2136
|
+
} catch (err) {
|
|
2137
|
+
res
|
|
2138
|
+
.writeHead(502, { 'content-type': 'application/json' })
|
|
2139
|
+
.end(JSON.stringify({ error: `gcs-redirect: could not sign a URL for gs://${bucket}/${object}: ${err instanceof Error ? err.message : String(err)}` }))
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2041
2143
|
const server = createServer(async (req, res) => {
|
|
2042
2144
|
if (!isAuthorized(req)) {
|
|
2043
2145
|
res
|
|
@@ -2484,6 +2586,11 @@ const server = createServer(async (req, res) => {
|
|
|
2484
2586
|
return
|
|
2485
2587
|
}
|
|
2486
2588
|
|
|
2589
|
+
if (req.method === 'GET' && pathname === '/gcs-redirect') {
|
|
2590
|
+
await handleGcsRedirect(req, res)
|
|
2591
|
+
return
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2487
2594
|
const match = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages$/)
|
|
2488
2595
|
if (!match) {
|
|
2489
2596
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not found' }))
|
|
@@ -2511,8 +2618,62 @@ const server = createServer(async (req, res) => {
|
|
|
2511
2618
|
const port = Number(process.env.PORT ?? 8787)
|
|
2512
2619
|
server.listen(port, () => console.log(`agent API listening on :${port}`))
|
|
2513
2620
|
|
|
2621
|
+
// server.close() alone only stops accepting *new* connections — it
|
|
2622
|
+
// doesn't wait for existing ones, and the old code called process.exit
|
|
2623
|
+
// right after it regardless, killing every still-open connection
|
|
2624
|
+
// immediately. A long-lived SSE stream (the Playground's own
|
|
2625
|
+
// /messages/stream, open for a whole turn — longer still if the model
|
|
2626
|
+
// polls a background job within that same turn) dies mid-response, and
|
|
2627
|
+
// whatever's in front of this process (a load balancer, a reverse
|
|
2628
|
+
// proxy) sees the backend vanish mid-stream and reports a 502 to
|
|
2629
|
+
// whoever was watching it — confirmed live: a `pm2 restart` while an
|
|
2630
|
+
// image-generation batch was in progress produced exactly that. Now
|
|
2631
|
+
// actually waits for server.close()'s own callback (fires once every
|
|
2632
|
+
// in-flight connection has closed on its own) before tearing anything
|
|
2633
|
+
// else down — bounded by LOOPENGINE_SHUTDOWN_GRACE_MS so a genuinely
|
|
2634
|
+
// stuck connection can't hang a deploy forever. Whatever's orchestrating
|
|
2635
|
+
// the restart (pm2's own kill_timeout, a container platform's own grace
|
|
2636
|
+
// period) needs to be at least this long too, or it'll SIGKILL out from
|
|
2637
|
+
// under this before the wait finishes — SIGKILL can't be caught or
|
|
2638
|
+
// delayed by anything here.
|
|
2639
|
+
//
|
|
2640
|
+
// This only protects the *connection* a request/stream is running
|
|
2641
|
+
// over. A tool's own background work that isn't awaited by its
|
|
2642
|
+
// execute() call (e.g. a batch job that returns a job_id immediately
|
|
2643
|
+
// and keeps working after) isn't tied to any connection at all, and
|
|
2644
|
+
// still dies the instant process.exit runs regardless of how long this
|
|
2645
|
+
// grace period is — there's no persistent job queue here surviving a
|
|
2646
|
+
// process restart, just this one process's own event loop.
|
|
2514
2647
|
async function shutdown() {
|
|
2515
|
-
|
|
2648
|
+
console.log('[loopengine] shutting down — draining in-flight requests...')
|
|
2649
|
+
const graceMs = Number(process.env.LOOPENGINE_SHUTDOWN_GRACE_MS ?? 30000)
|
|
2650
|
+
const closed = new Promise<void>((resolve) => server.close(() => resolve()))
|
|
2651
|
+
// HTTP keep-alive means a connection whose last request already
|
|
2652
|
+
// finished doesn't close itself — it sits open, idle, waiting for a
|
|
2653
|
+
// *next* request that (mid-restart) is never coming, which would
|
|
2654
|
+
// otherwise hold server.close()'s own callback back for the entire
|
|
2655
|
+
// grace period even though nothing real is still in flight on it —
|
|
2656
|
+
// confirmed live: an idle keep-alive socket alone reproduced the full
|
|
2657
|
+
// wait. closeIdleConnections() (Node >= 18.2) drops exactly those
|
|
2658
|
+
// (never an actively-streaming request/response, which is the one
|
|
2659
|
+
// thing this whole function exists to protect) right away — but only
|
|
2660
|
+
// the ones idle *at the instant it's called*. A connection that's
|
|
2661
|
+
// still active right now (the actual case this whole function exists
|
|
2662
|
+
// to protect) goes idle *later*, once its own response finishes, so a
|
|
2663
|
+
// single call up front misses it — confirmed live: without the
|
|
2664
|
+
// repeated sweep below, that connection's own idle socket still held
|
|
2665
|
+
// the grace period open right up to the timeout, same as if this call
|
|
2666
|
+
// were never made at all. Repeating it on an interval catches that as
|
|
2667
|
+
// soon as it happens, instead of only ever catching sockets already
|
|
2668
|
+
// idle before shutdown even began.
|
|
2669
|
+
server.closeIdleConnections()
|
|
2670
|
+
const sweep = setInterval(() => server.closeIdleConnections(), 1000)
|
|
2671
|
+
const timedOut = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), graceMs))
|
|
2672
|
+
const outcome = await Promise.race([closed.then(() => 'closed' as const), timedOut])
|
|
2673
|
+
clearInterval(sweep)
|
|
2674
|
+
if (outcome === 'timeout') {
|
|
2675
|
+
console.log(`[loopengine] shutdown grace period (${graceMs}ms) elapsed with connections still open — exiting anyway`)
|
|
2676
|
+
}
|
|
2516
2677
|
await sessions.close()
|
|
2517
2678
|
process.exit(0)
|
|
2518
2679
|
}
|