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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- server.close()
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
  }