pinokiod 7.0.4 → 7.0.6

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": "pinokiod",
3
- "version": "7.0.4",
3
+ "version": "7.0.6",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -52,6 +52,7 @@ Follow these sections in order:
52
52
  2. Only use Registry Fallback if Search App found no suitable installed app and the user approved it.
53
53
  3. Then use Run App.
54
54
  4. Then use API Call Strategy if the app exposes an automatable API.
55
+ 5. Only use Parallel Mode when the user explicitly asks to use multiple apps or multiple machines in parallel.
55
56
 
56
57
  ### 1. Search App
57
58
 
@@ -89,7 +90,8 @@ Follow these sections in order:
89
90
  - If the top candidate is not clearly better than alternatives, ask user once with top 3 candidates.
90
91
  - If a suitable installed app is found, select it and continue to Run App.
91
92
  - Search results may include apps from other reachable Pinokio machines:
92
- - remote results use `app_id` in the form `<app_id>@<source.host>`
93
+ - prefer the canonical `ref` field when it exists
94
+ - `ref` uses the form `pinokio://<host>:<port>/<scope>/<id>`
93
95
  - `source.local=false` means the result is from another machine
94
96
  - treat remote results as separate apps; do not merge them with the local app of the same name
95
97
 
@@ -112,6 +114,7 @@ Follow these sections in order:
112
114
  - Poll every 2s.
113
115
  - Use status fields from pterm output:
114
116
  - `path`: absolute app path to use with `pterm run`
117
+ - `ref`: canonical Pinokio resource reference in the form `pinokio://<host>:<port>/<scope>/<id>`
115
118
  - `running`: script is running
116
119
  - `ready`: app is reachable/ready
117
120
  - `ready_url`: default base URL for API calls when available
@@ -121,21 +124,21 @@ Follow these sections in order:
121
124
  - Use `--probe` only for readiness confirmation before first API call (or when status is uncertain).
122
125
  - Use `--timeout=<ms>` only when you need a non-default probe timeout.
123
126
  - Treat `offline` as expected before first run.
124
- - If `app_id` contains `@<host>` or `source.local=false`, the app is remote:
127
+ - If `ref` points to another machine or `source.local=false`, the app is remote:
125
128
  - treat `path` and `ready_url` as source-local fields, not caller-usable local paths/URLs
126
129
  - use `external_ready_urls` in order for caller-side API access when available
127
- - use `pterm run <app_id>` for remote launch; do not use a remote machine's `path` value as a local path
130
+ - use `pterm run <ref>` for remote launch; do not use a remote machine's `path` value as a local path
128
131
  - for remote path-based tasks:
129
132
  - this applies only when the task expects filesystem paths such as `/path/to/file`
130
133
  - do not pass local paths from this machine to the remote app
131
- - first run `pterm upload <app_id> <file...>`
134
+ - first run `pterm upload <ref> <file...>`
132
135
  - then use the returned remote `path` values
133
136
  - If app is offline or not ready, run it:
134
- - For remote apps, run `pterm run <app_id>`.
137
+ - If `ref` exists, run `pterm run <ref>`.
135
138
  - Otherwise run `pterm run <app_path>`.
136
139
  - If the launcher has no explicit default item or the launch action depends on current menu state, infer one or more ordered selectors from the launcher's current menu and pass them via repeated `--default`.
137
140
  - Prefer stable launcher selectors such as `run.js?mode=Default`, then broader fallbacks like `run.js`, then installation fallback like `install.js`.
138
- - Continue polling with `pterm status <app_id>`.
141
+ - Continue polling with `pterm status <ref>` when `ref` exists, otherwise `pterm status <app_id>`.
139
142
  - Default startup timeout: 180s.
140
143
  - Success criteria:
141
144
  - `state=online` and `ready=true`
@@ -146,7 +149,7 @@ Follow these sections in order:
146
149
  - timeout before success
147
150
  - app drops back to `offline` during startup after a run attempt
148
151
  - `pterm run` terminates and status never reaches ready
149
- - on failure, fetch `pterm logs <app_id> --tail 200` and return:
152
+ - on failure, fetch `pterm logs <ref> --tail 200` when `ref` exists, otherwise `pterm logs <app_id> --tail 200`, and return:
150
153
  - raw log tail
151
154
  - short diagnosis
152
155
 
@@ -173,6 +176,27 @@ Follow these sections in order:
173
176
  - Do not execute the app's internal Python/Node/bundled CLI as a fallback when `pterm` has already selected a launcher-managed app.
174
177
  - If no automatable API exists after the app is running, report that clearly instead of bypassing the launcher with an internal CLI.
175
178
 
179
+ ### 5. Parallel Mode (explicit only)
180
+
181
+ - Use this section only when the user explicitly asks to:
182
+ - run on multiple machines
183
+ - use multiple apps in parallel
184
+ - compare multiple relevant apps side by side
185
+ - generate multiple outputs concurrently
186
+ - Do not use this mode by default.
187
+ - Keep each selected app as a separate target. Prefer `ref` as the target identifier when it exists.
188
+ - Selection rules:
189
+ - if the user asks for all relevant apps, use all relevant search results that can perform the task
190
+ - if the user asks for a specific count, use the top N relevant search results after normal search ranking
191
+ - if the user asks for parallel use but does not specify how many apps or machines to use, ask once
192
+ - Ranking still applies in this mode:
193
+ - prefer `ready` apps first
194
+ - then `running` apps
195
+ - then offline apps if more targets are still needed
196
+ - Run and monitor each selected target independently.
197
+ - Keep outputs labeled by target `ref` when it exists, otherwise `app_id`.
198
+ - For remote path-based tasks, run `pterm upload <ref> <file...>` separately for each remote target when `ref` exists, otherwise fall back to `app_id`.
199
+
176
200
  ## Behavior Rules
177
201
 
178
202
  - Do not add app-specific hardcoding when user gave only capability (for example "tts").
@@ -11,6 +11,7 @@ const DEFAULT_PEER_PORT = 42000
11
11
  const DEFAULT_PEER_TIMEOUT_MS = 2500
12
12
  const DEFAULT_PEER_UPLOAD_TIMEOUT_MS = 30000
13
13
  const IPV4_HOST_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
14
+ const PINOKIO_REF_PROTOCOL = 'pinokio:'
14
15
 
15
16
  const isQualifiedHost = (value = '') => {
16
17
  return IPV4_HOST_PATTERN.test(String(value || '').trim())
@@ -56,6 +57,84 @@ const parseQualifiedAppId = (value = '') => {
56
57
  }
57
58
  }
58
59
 
60
+ const isLoopbackHost = (value = '') => {
61
+ const normalized = String(value || '').trim().toLowerCase()
62
+ return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' || normalized === '[::1]'
63
+ }
64
+
65
+ const parsePinokioRef = (value = '') => {
66
+ if (typeof value !== 'string') {
67
+ return {
68
+ valid: false,
69
+ error: 'Invalid ref'
70
+ }
71
+ }
72
+ const trimmed = value.trim()
73
+ if (!trimmed) {
74
+ return {
75
+ valid: false,
76
+ error: 'Missing ref'
77
+ }
78
+ }
79
+ let parsed
80
+ try {
81
+ parsed = new URL(trimmed)
82
+ } catch (_) {
83
+ return {
84
+ valid: false,
85
+ error: 'Invalid ref'
86
+ }
87
+ }
88
+ if (parsed.protocol !== PINOKIO_REF_PROTOCOL) {
89
+ return {
90
+ valid: false,
91
+ error: 'Invalid ref protocol'
92
+ }
93
+ }
94
+ const host = typeof parsed.hostname === 'string' ? parsed.hostname.trim() : ''
95
+ const port = Number.parseInt(String(parsed.port || ''), 10)
96
+ const pathSegments = String(parsed.pathname || '')
97
+ .split('/')
98
+ .filter(Boolean)
99
+ .map((segment) => {
100
+ try {
101
+ return decodeURIComponent(segment)
102
+ } catch (_) {
103
+ return segment
104
+ }
105
+ })
106
+ const scope = pathSegments.length > 0 ? pathSegments[0] : ''
107
+ const id = pathSegments.length > 1 ? pathSegments.slice(1).join('/') : ''
108
+ if (!host || !Number.isFinite(port) || port <= 0 || !scope || !id) {
109
+ return {
110
+ valid: false,
111
+ error: 'Invalid ref'
112
+ }
113
+ }
114
+ return {
115
+ valid: true,
116
+ ref: trimmed,
117
+ host,
118
+ port,
119
+ scope,
120
+ id
121
+ }
122
+ }
123
+
124
+ const buildPinokioRef = ({ host, port, scope, id }) => {
125
+ const normalizedHost = typeof host === 'string' ? host.trim() : ''
126
+ const normalizedScope = typeof scope === 'string' ? scope.trim() : ''
127
+ const normalizedId = typeof id === 'string' ? id.trim() : ''
128
+ const normalizedPort = Number.parseInt(String(port || ''), 10)
129
+ if (!normalizedHost || !normalizedScope || !normalizedId || !Number.isFinite(normalizedPort) || normalizedPort <= 0) {
130
+ return null
131
+ }
132
+ const encodedPath = [normalizedScope, ...normalizedId.split('/').filter(Boolean)]
133
+ .map((segment) => encodeURIComponent(segment))
134
+ .join('/')
135
+ return `pinokio://${normalizedHost}:${normalizedPort}/${encodedPath}`
136
+ }
137
+
59
138
  module.exports = function registerAppRoutes(app, { registry, preferences, appSearch, appLogs, getTheme }) {
60
139
  if (!app || !registry || !preferences || !appSearch || !appLogs) {
61
140
  throw new Error('App routes require app, registry, preferences, appSearch, and appLogs')
@@ -120,6 +199,43 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
120
199
  }
121
200
  return `${normalizedAppId}@${host}`
122
201
  }
202
+ const canonicalRefHost = (source, overrideHost = '') => {
203
+ const hostOverride = typeof overrideHost === 'string' ? overrideHost.trim() : ''
204
+ if (hostOverride) {
205
+ return hostOverride
206
+ }
207
+ if (source && typeof source.host === 'string' && source.host.trim()) {
208
+ return source.host.trim()
209
+ }
210
+ return currentPeerHost() || '127.0.0.1'
211
+ }
212
+ const isLocalPinokioRef = (parsedRef) => {
213
+ if (!parsedRef || !parsedRef.valid) {
214
+ return false
215
+ }
216
+ if (parsedRef.port !== peerPort()) {
217
+ return false
218
+ }
219
+ const localHost = currentPeerHost()
220
+ return isLoopbackHost(parsedRef.host) || (localHost && parsedRef.host === localHost)
221
+ }
222
+ const attachApiRef = (payload, source, appId, options = {}) => {
223
+ const next = payload && typeof payload === 'object' ? { ...payload } : {}
224
+ const normalizedAppId = typeof appId === 'string' ? appId.trim() : ''
225
+ if (!normalizedAppId) {
226
+ return next
227
+ }
228
+ const ref = buildPinokioRef({
229
+ host: canonicalRefHost(source, options.host),
230
+ port: Number.parseInt(String(options.port || peerPort()), 10) || peerPort(),
231
+ scope: 'api',
232
+ id: normalizedAppId
233
+ })
234
+ if (ref) {
235
+ next.ref = ref
236
+ }
237
+ return next
238
+ }
123
239
  const neutralizeRemoteSearchPreferences = (appResult) => {
124
240
  const next = appResult && typeof appResult === 'object' ? { ...appResult } : {}
125
241
  next.starred = false
@@ -153,25 +269,31 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
153
269
  const parsed = Date.parse(value)
154
270
  return Number.isFinite(parsed) ? parsed : 0
155
271
  }
156
- const decorateSearchResult = (appResult, source) => {
272
+ const decorateSearchResult = (appResult, source, options = {}) => {
157
273
  const next = source && !source.local
158
274
  ? neutralizeRemoteSearchPreferences(appResult)
159
275
  : (appResult && typeof appResult === 'object' ? { ...appResult } : {})
160
- next.app_id = qualifyAppId(next.app_id || next.name || '', source.host)
276
+ const resourceId = typeof next.app_id === 'string' && next.app_id.trim()
277
+ ? next.app_id.trim()
278
+ : (typeof next.name === 'string' ? next.name.trim() : '')
279
+ next.app_id = qualifyAppId(resourceId, source.host)
161
280
  next.source = source
162
281
  if (!source.local) {
163
282
  next.ready_url = null
164
283
  }
165
- return next
284
+ return attachApiRef(next, source, resourceId, options)
166
285
  }
167
- const decorateStatusResult = (statusResult, source) => {
286
+ const decorateStatusResult = (statusResult, source, options = {}) => {
168
287
  const next = statusResult && typeof statusResult === 'object' ? { ...statusResult } : {}
169
- next.app_id = qualifyAppId(next.app_id || next.name || '', source.host)
288
+ const resourceId = typeof next.app_id === 'string' && next.app_id.trim()
289
+ ? next.app_id.trim()
290
+ : (typeof next.name === 'string' ? next.name.trim() : '')
291
+ next.app_id = qualifyAppId(resourceId, source.host)
170
292
  next.source = source
171
293
  if (!source.local) {
172
294
  next.ready_url = null
173
295
  }
174
- return next
296
+ return attachApiRef(next, source, resourceId, options)
175
297
  }
176
298
  const peerRequestHeaders = (req) => {
177
299
  const headers = {
@@ -245,11 +367,11 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
245
367
  files: stored
246
368
  }
247
369
  }
248
- const decorateUploadResult = (uploadResult, source, appId) => {
370
+ const decorateUploadResult = (uploadResult, source, appId, options = {}) => {
249
371
  const next = uploadResult && typeof uploadResult === 'object' ? { ...uploadResult } : {}
250
372
  next.app_id = qualifyAppId(appId || next.app_id || '', source.host)
251
373
  next.source = source
252
- return next
374
+ return attachApiRef(next, source, appId || next.app_id || '', options)
253
375
  }
254
376
  const mergeSearchApps = (localApps, remoteApps, query = '') => {
255
377
  const merged = []
@@ -400,6 +522,255 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
400
522
  res.json({ apps })
401
523
  }))
402
524
 
525
+ router.get('/pinokio/resource/status', asyncHandler(async (req, res) => {
526
+ const parsedRef = parsePinokioRef(typeof req.query.ref === 'string' ? req.query.ref : '')
527
+ if (!parsedRef.valid) {
528
+ res.status(400).json({ error: parsedRef.error || 'Invalid ref' })
529
+ return
530
+ }
531
+ if (parsedRef.scope !== 'api') {
532
+ res.status(400).json({ error: `Unsupported ref scope: ${parsedRef.scope}` })
533
+ return
534
+ }
535
+ const canonicalRef = buildPinokioRef(parsedRef)
536
+ if (!isLocalPinokioRef(parsedRef)) {
537
+ try {
538
+ const timeout = Number.parseInt(String(req.query.timeout || ''), 10)
539
+ const params = { ref: canonicalRef }
540
+ if (typeof req.query.probe !== 'undefined') {
541
+ params.probe = req.query.probe
542
+ }
543
+ if (Number.isFinite(timeout)) {
544
+ params.timeout = String(timeout)
545
+ }
546
+ const response = await axios.get(`http://${parsedRef.host}:${parsedRef.port}/pinokio/resource/status`, {
547
+ timeout: DEFAULT_PEER_TIMEOUT_MS,
548
+ headers: peerRequestHeaders(req),
549
+ params
550
+ })
551
+ const payload = decorateStatusResult(response.data, buildSource(parsedRef.host, false), {
552
+ host: parsedRef.host,
553
+ port: parsedRef.port
554
+ })
555
+ payload.app_id = parsedRef.id
556
+ payload.ref = canonicalRef
557
+ res.json(payload)
558
+ return
559
+ } catch (error) {
560
+ if (error && error.response) {
561
+ res.status(error.response.status).json(error.response.data)
562
+ return
563
+ }
564
+ res.status(502).json({
565
+ error: 'Peer resource status unavailable',
566
+ ref: canonicalRef,
567
+ source: buildSource(parsedRef.host, false)
568
+ })
569
+ return
570
+ }
571
+ }
572
+ const appId = registry.normalizeAppId(parsedRef.id)
573
+ if (!appId) {
574
+ res.status(400).json({ error: 'Invalid app_id', ref: canonicalRef })
575
+ return
576
+ }
577
+ const probe = registry.parseBooleanQuery(req.query.probe, false)
578
+ const timeout = Number.parseInt(String(req.query.timeout || ''), 10)
579
+ const status = await registry.buildAppStatus(appId, {
580
+ probe,
581
+ timeout: Number.isFinite(timeout) ? timeout : 1500,
582
+ source: req.$source || null
583
+ })
584
+ if (!status) {
585
+ res.status(404).json({ error: 'App not found', ref: canonicalRef })
586
+ return
587
+ }
588
+ status.preference = await preferences.getPreference(appId)
589
+ const payload = decorateStatusResult(status, buildSource(currentPeerHost(), true), {
590
+ host: parsedRef.host,
591
+ port: parsedRef.port
592
+ })
593
+ payload.app_id = appId
594
+ payload.ref = canonicalRef
595
+ res.json(payload)
596
+ }))
597
+
598
+ router.get('/pinokio/resource/logs', asyncHandler(async (req, res) => {
599
+ const parsedRef = parsePinokioRef(typeof req.query.ref === 'string' ? req.query.ref : '')
600
+ if (!parsedRef.valid) {
601
+ res.status(400).json({ error: parsedRef.error || 'Invalid ref' })
602
+ return
603
+ }
604
+ if (parsedRef.scope !== 'api') {
605
+ res.status(400).json({ error: `Unsupported ref scope: ${parsedRef.scope}` })
606
+ return
607
+ }
608
+ const canonicalRef = buildPinokioRef(parsedRef)
609
+ if (!isLocalPinokioRef(parsedRef)) {
610
+ try {
611
+ const params = { ref: canonicalRef }
612
+ if (typeof req.query.script === 'string' && req.query.script.trim()) {
613
+ params.script = req.query.script
614
+ }
615
+ const tail = registry.parseTailCount(req.query.tail, 200)
616
+ if (Number.isFinite(tail) && tail > 0) {
617
+ params.tail = String(tail)
618
+ }
619
+ const response = await axios.get(`http://${parsedRef.host}:${parsedRef.port}/pinokio/resource/logs`, {
620
+ timeout: DEFAULT_PEER_TIMEOUT_MS,
621
+ headers: peerRequestHeaders(req),
622
+ params
623
+ })
624
+ const payload = response && response.data && typeof response.data === 'object'
625
+ ? { ...response.data }
626
+ : {}
627
+ payload.app_id = parsedRef.id
628
+ payload.ref = canonicalRef
629
+ payload.source = buildSource(parsedRef.host, false)
630
+ res.json(payload)
631
+ return
632
+ } catch (error) {
633
+ if (error && error.response) {
634
+ res.status(error.response.status).json(error.response.data)
635
+ return
636
+ }
637
+ res.status(502).json({
638
+ error: 'Peer resource logs unavailable',
639
+ ref: canonicalRef,
640
+ source: buildSource(parsedRef.host, false)
641
+ })
642
+ return
643
+ }
644
+ }
645
+ const appId = registry.normalizeAppId(parsedRef.id)
646
+ if (!appId) {
647
+ res.status(400).json({ error: 'Invalid app_id', ref: canonicalRef })
648
+ return
649
+ }
650
+ const status = await registry.buildAppStatus(appId, {
651
+ source: req.$source || null
652
+ })
653
+ if (!status) {
654
+ res.status(404).json({ error: 'App not found', ref: canonicalRef })
655
+ return
656
+ }
657
+ const tail = registry.parseTailCount(req.query.tail, 200)
658
+ const scriptQuery = typeof req.query.script === 'string' ? req.query.script : ''
659
+ const resolvedLog = await appLogs.resolveAppLogFile(status.path, scriptQuery, status.running_scripts)
660
+ if (resolvedLog && resolvedLog.error === 'INVALID_SCRIPT') {
661
+ res.status(400).json({ error: 'Invalid script path', ref: canonicalRef })
662
+ return
663
+ }
664
+ if (!resolvedLog || !resolvedLog.file) {
665
+ res.status(404).json({
666
+ error: 'No log file found',
667
+ ref: canonicalRef,
668
+ script: scriptQuery || null
669
+ })
670
+ return
671
+ }
672
+ const logData = await appLogs.readLogTail(resolvedLog.file, tail)
673
+ res.json({
674
+ app_id: appId,
675
+ ref: canonicalRef,
676
+ source: buildSource(currentPeerHost(), true),
677
+ script: resolvedLog.script,
678
+ file: registry.toPosixRelative(status.path, resolvedLog.file),
679
+ ...logData
680
+ })
681
+ }))
682
+
683
+ router.post('/pinokio/resource/upload', upload.any(), asyncHandler(async (req, res) => {
684
+ const rawRef = typeof req.query.ref === 'string' && req.query.ref.trim()
685
+ ? req.query.ref
686
+ : (req.body && typeof req.body.ref === 'string' ? req.body.ref : '')
687
+ const parsedRef = parsePinokioRef(rawRef)
688
+ if (!parsedRef.valid) {
689
+ res.status(400).json({ error: parsedRef.error || 'Invalid ref' })
690
+ return
691
+ }
692
+ if (parsedRef.scope !== 'api') {
693
+ res.status(400).json({ error: `Unsupported ref scope: ${parsedRef.scope}` })
694
+ return
695
+ }
696
+ const canonicalRef = buildPinokioRef(parsedRef)
697
+ const files = Array.isArray(req.files) ? req.files : []
698
+ if (files.length === 0) {
699
+ res.status(400).json({ error: 'No files provided', ref: canonicalRef })
700
+ return
701
+ }
702
+ if (!isLocalPinokioRef(parsedRef)) {
703
+ try {
704
+ const form = new FormData()
705
+ for (const file of files) {
706
+ if (!file || !file.buffer) {
707
+ continue
708
+ }
709
+ form.append('files', file.buffer, {
710
+ filename: path.basename(file.originalname || 'upload'),
711
+ contentType: file.mimetype || 'application/octet-stream',
712
+ knownLength: typeof file.size === 'number' ? file.size : file.buffer.length
713
+ })
714
+ }
715
+ const response = await axios.post(`http://${parsedRef.host}:${parsedRef.port}/pinokio/resource/upload`, form, {
716
+ timeout: DEFAULT_PEER_UPLOAD_TIMEOUT_MS,
717
+ maxBodyLength: Infinity,
718
+ maxContentLength: Infinity,
719
+ headers: {
720
+ ...peerRequestHeaders(req),
721
+ ...form.getHeaders()
722
+ },
723
+ params: {
724
+ ref: canonicalRef
725
+ }
726
+ })
727
+ const payload = response && response.data && typeof response.data === 'object'
728
+ ? { ...response.data }
729
+ : {}
730
+ payload.app_id = parsedRef.id
731
+ payload.ref = canonicalRef
732
+ payload.source = buildSource(parsedRef.host, false)
733
+ res.json(payload)
734
+ return
735
+ } catch (error) {
736
+ if (error && error.response) {
737
+ res.status(error.response.status).json(error.response.data)
738
+ return
739
+ }
740
+ res.status(502).json({
741
+ error: 'Peer resource upload unavailable',
742
+ ref: canonicalRef,
743
+ source: buildSource(parsedRef.host, false)
744
+ })
745
+ return
746
+ }
747
+ }
748
+ const appId = registry.normalizeAppId(parsedRef.id)
749
+ if (!appId) {
750
+ res.status(400).json({ error: 'Invalid app_id', ref: canonicalRef })
751
+ return
752
+ }
753
+ const status = await registry.buildAppStatus(appId, {
754
+ source: req.$source || null
755
+ })
756
+ if (!status || !status.path) {
757
+ res.status(404).json({ error: 'App not found', ref: canonicalRef })
758
+ return
759
+ }
760
+ const payload = await storeAppUploads(status.path, files)
761
+ if (!Array.isArray(payload.files) || payload.files.length === 0) {
762
+ res.status(400).json({ error: 'No valid files provided', ref: canonicalRef })
763
+ return
764
+ }
765
+ const decorated = decorateUploadResult(payload, buildSource(currentPeerHost(), true), appId, {
766
+ host: parsedRef.host,
767
+ port: parsedRef.port
768
+ })
769
+ decorated.app_id = appId
770
+ decorated.ref = canonicalRef
771
+ res.json(decorated)
772
+ }))
773
+
403
774
  router.get('/apps/search', asyncHandler(async (req, res) => {
404
775
  const q = typeof req.query.q === 'string' ? req.query.q : ''
405
776
  const mode = typeof req.query.mode === 'string' ? req.query.mode : ''
@@ -577,7 +948,51 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
577
948
  }))
578
949
 
579
950
  router.get('/apps/logs/:app_id', asyncHandler(async (req, res) => {
580
- const appId = registry.normalizeAppId(req.params.app_id)
951
+ const parsedAppId = parseQualifiedAppId(req.params.app_id)
952
+ const requestedAppId = parsedAppId.app_id || req.params.app_id
953
+ const remoteHost = parsedAppId.qualified ? parsedAppId.host : null
954
+ if (remoteHost && remoteHost !== currentPeerHost()) {
955
+ try {
956
+ const params = {}
957
+ if (typeof req.query.script === 'string' && req.query.script.trim()) {
958
+ params.script = req.query.script
959
+ }
960
+ const tail = registry.parseTailCount(req.query.tail, 200)
961
+ if (Number.isFinite(tail) && tail > 0) {
962
+ params.tail = String(tail)
963
+ }
964
+ const response = await axios.get(`http://${remoteHost}:${peerPort()}/apps/logs/${encodeURIComponent(requestedAppId)}`, {
965
+ timeout: DEFAULT_PEER_TIMEOUT_MS,
966
+ headers: peerRequestHeaders(req),
967
+ params
968
+ })
969
+ const payload = response && response.data && typeof response.data === 'object'
970
+ ? { ...response.data }
971
+ : {}
972
+ payload.app_id = qualifyAppId(requestedAppId, remoteHost)
973
+ payload.source = buildSource(remoteHost, false)
974
+ payload.ref = buildPinokioRef({
975
+ host: remoteHost,
976
+ port: peerPort(),
977
+ scope: 'api',
978
+ id: requestedAppId
979
+ })
980
+ res.json(payload)
981
+ return
982
+ } catch (error) {
983
+ if (error && error.response) {
984
+ res.status(error.response.status).json(error.response.data)
985
+ return
986
+ }
987
+ res.status(502).json({
988
+ error: 'Peer logs unavailable',
989
+ app_id: qualifyAppId(requestedAppId, remoteHost),
990
+ source: buildSource(remoteHost, false)
991
+ })
992
+ return
993
+ }
994
+ }
995
+ const appId = registry.normalizeAppId(requestedAppId)
581
996
  if (!appId) {
582
997
  res.status(400).json({ error: 'Invalid app_id' })
583
998
  return
@@ -607,6 +1022,12 @@ module.exports = function registerAppRoutes(app, { registry, preferences, appSea
607
1022
  const logData = await appLogs.readLogTail(resolvedLog.file, tail)
608
1023
  res.json({
609
1024
  app_id: appId,
1025
+ ref: buildPinokioRef({
1026
+ host: currentPeerHost() || '127.0.0.1',
1027
+ port: peerPort(),
1028
+ scope: 'api',
1029
+ id: appId
1030
+ }),
610
1031
  script: resolvedLog.script,
611
1032
  source: resolvedLog.source,
612
1033
  file: registry.toPosixRelative(status.path, resolvedLog.file),