@synopackageland/templates 0.26.5 → 0.27.1

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.
@@ -43,16 +43,13 @@ Refresh and save expose busy state and localized errors. Reopen refreshes status
43
43
  Each mount owns its state; unmount unsubscribes from host events and ignores late
44
44
  responses. Backend route policies decide access.
45
45
 
46
- ### Opt-in background schedules (S1-a)
46
+ ### Background jobs
47
47
 
48
- The `native-go-jobs` overlay extends this **Operate** surface within the inherited `AppRoot`, embedded theme, `SettingsSection`, Button and Callout system. It introduces no new palette or global tokens.
49
-
50
- - **Private-copy consent:** “Schedule this copy” is available for the signed-in owner's retained, successful, unscheduled job copy. “Private copy: {name}” identifies the input; “Repeat every” offers “Hour”, “Day” and “Week”. “Create schedule” stays disabled until the user checks “I agree to keep and reprocess this fixed copy as the package user. No new source files or personal ACLs are read.” The help explains the eight-schedule limit and that missed runs are recorded, never replayed.
51
- - **Ownership and recovery:** preserve user-and-UID owner isolation. Administrator rows expose “Owner: {user} (UID {uid})”. A worker change shows “The worker revision changed. An administrator must review and explicitly enable this schedule.” Only administrators, while background mode is on, see “Enable schedule”. Its confirmation names the exact private copy: “Enable the schedule for {name} with the current worker revision? I agree to reprocess this fixed copy. Past runs will not be replayed.” Actions are “Confirm enable” and “Cancel”.
52
- - **Layout:** schedule name/state, owner, next run, missed runs and warnings stack above a grouped, wrapping action row. Long names and copy wrap within the available width. Keep the inherited vertical scrolling at the minimum window size; do not squeeze schedules into one viewport. The labeled select and consent checkbox retain visible keyboard focus using existing theme tokens.
53
- - **Errors and cleanup:** danger alerts map known error codes to localized safe copy, with a localized fallback rather than raw server text. The catalogs cover nine locales. Preserve per-mount disposal: stop polling, dispose the picker, revoke result download URLs and prevent pending responses from restoring disposed state. Deletion confirmation explains that queued jobs cancel, running jobs drain and finished results remain.
54
-
55
- This layout is checked against the template's local component fixtures at the desktop size and at the DSM minimum window size, including the states and recovery copy above. That validates the component contract only; visual acceptance on a specific DSM release is the App developer's own check.
48
+ Job producers import their own input into the App data directory. The backend
49
+ keeps submission, progress, cancellation and private-copy scheduling contracts;
50
+ the shell does not pick or import NAS files. App-specific job controls can use
51
+ the installed SDK and UI components. Keep explicit consent for schedules and
52
+ user-and-UID ownership isolation, and stop polling on unmount.
56
53
 
57
54
  ## Do's and Don'ts
58
55
 
@@ -2,7 +2,7 @@
2
2
  import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
3
3
  import {Button, Callout, Form, FormActions, FormItem, KeyValueRow, SettingsSection, Textarea} from '@synopackageland/ui'
4
4
  import {bridgeErrorCopy, sharedFolderAccessFor, type AppStatus, type Context, type ShellPage} from '@synopackageland/sdk'
5
- import {AppShell, BackgroundPanel, FilesPage, LoginPage, PortalSettings, SharedFolderSettings, createSession, shellPages} from '@synopackageland/sdk/vue'
5
+ import {AppShell, FilesPage, LoginPage, PortalSettings, SharedFolderSettings, createSession, shellPages} from '@synopackageland/sdk/vue'
6
6
  import {appCopy} from './i18n'
7
7
  import CapabilityPanels from './CapabilityPanels.vue'
8
8
  import ExecutorGate, {holdsPages} from './ExecutorGate.vue'
@@ -66,11 +66,10 @@ async function refresh() {
66
66
  }
67
67
  // The header Refresh also reloads the panels below and reads the portal again.
68
68
  const panels = ref<{refresh(): unknown}>()
69
- const background = ref<{refresh(): unknown}>()
70
69
  const portalReads = ref(0)
71
70
  const portalKey = computed(() => `${app.value?.user}:${portalReads.value}`)
72
71
  function refreshAll() {
73
- void refresh(); void panels.value?.refresh(); void background.value?.refresh(); portalReads.value++
72
+ void refresh(); void panels.value?.refresh(); portalReads.value++
74
73
  }
75
74
  async function save() {
76
75
  if(disposed || busy.value) return
@@ -122,7 +121,6 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
122
121
  <CapabilityPanels v-if="app" ref="panels" :key="app.user" :ctx="ctx" :admin="app.admin" />
123
122
  <FilesPage v-if="page==='status'" :ctx="ctx" />
124
123
  <div data-page="background" v-show="page==='background'">
125
- <BackgroundPanel v-if="backgroundEnabled && app" ref="background" :key="app.user" :ctx="ctx" />
126
124
  </div>
127
125
  </div>
128
126
  </ExecutorGate>
@@ -111,7 +111,7 @@ rebuilding the SPK.
111
111
 
112
112
  | `--with` | Methods | Runtime services |
113
113
  | --- | --- | --- |
114
- | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `files.read` with the caller's session, `bg.submit`, `bg.get`, `bg.cancel` |
114
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
115
115
  | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
116
116
  | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
117
117
 
@@ -158,3 +158,13 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
158
158
  | Build hosts | macOS, Linux |
159
159
  | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
160
160
  | DSM | 7.1 or later declared (`os_min_ver`) |
161
+
162
+ ## Background job input
163
+
164
+ Jobs process an existing file imported by the App's producer into its private
165
+ data directory. The submission params are `{key, name, input}`: an idempotency
166
+ key, a display name and an absolute path inside that directory. The SDK keeps a
167
+ bounded private copy for the worker; no browser session or NAS picker is used.
168
+ Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
169
+ cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
170
+ services; the runtime checks the input path and binds ownership to the caller.
@@ -1,38 +1,11 @@
1
- // Background jobs: hash a NAS file the caller may read.
2
- //
3
- // The SDK's worker runs each job as the package account: it hashes the job's
4
- // input (SHA-256) and keeps the result for the job's owner, the caller. The
5
- // runtime serves files.read with the caller's DSM session and bg.* for the
6
- // caller's own jobs only.
7
- import { copyFileSync, mkdirSync, unlinkSync } from 'node:fs'
8
- import { randomUUID } from 'node:crypto'
9
- import { basename, join } from 'node:path'
10
- import { AppError, type App, type Context } from '@synopackageland/app'
1
+ // Background jobs over files already imported into the App data directory.
2
+ import type { App } from '@synopackageland/app'
11
3
  import { strings } from './params.js'
12
4
  import type { Config } from './routes.js'
13
5
  import { service } from './service.js'
14
6
 
15
- /** A job's input is at most 512 KiB, copied into the job when it is submitted. */
16
- const MAX_INPUT = 512 * 1024
17
-
18
- export function register(app: App, cfg: Config): void {
19
- app.method('hash.submit', (ctx, params) => submit(cfg, ctx, params))
7
+ export function register(app: App, _cfg: Config): void {
8
+ app.method('hash.submit', (ctx, params) => service(ctx, 'bg.submit', strings(params, 'key', 'name', 'input')))
20
9
  app.method('hash.get', (ctx, params) => service(ctx, 'bg.get', strings(params, 'id')))
21
10
  app.method('hash.cancel', (ctx, params) => service(ctx, 'bg.cancel', strings(params, 'id')))
22
11
  }
23
-
24
- async function submit(cfg: Config, ctx: Context, params: unknown) {
25
- const { key, path } = strings(params, 'key', 'path')
26
- const read = await service<{ path: string; size: number }>(ctx, 'files.read', { path })
27
- if (read.size > MAX_INPUT) throw new AppError('limit_exceeded', 'The file is larger than allowed.')
28
- // bg.submit takes its input from the data directory; the call's spool is elsewhere.
29
- const inputs = join(cfg.dataDir, 'hash-inputs')
30
- mkdirSync(inputs, { recursive: true, mode: 0o700 })
31
- const input = join(inputs, randomUUID())
32
- copyFileSync(read.path, input)
33
- try {
34
- return await service(ctx, 'bg.submit', { key, name: basename(path), input })
35
- } finally {
36
- unlinkSync(input)
37
- }
38
- }
@@ -1,64 +1,31 @@
1
1
  import assert from 'node:assert/strict'
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
- import { join, sep } from 'node:path'
4
+ import { join } from 'node:path'
5
5
  import { afterEach, beforeEach, test } from 'node:test'
6
6
  import { build, type Config } from '../src/routes.js'
7
- import { Refusal, call } from './support.js'
7
+ import { call } from './support.js'
8
8
 
9
9
  const JOB = { job: { id: 'j1', state: 'queued' }, duplicate: false }
10
10
  let root: string
11
11
  let cfg: Config
12
- let submitted: Record<string, unknown>[]
13
12
 
14
13
  beforeEach(() => {
15
14
  root = mkdtempSync(join(tmpdir(), 'app-'))
16
15
  cfg = { packageId: 'ExampleApp', dataDir: join(root, 'var'), packagesRoot: join(root, 'packages') }
17
16
  mkdirSync(cfg.dataDir)
18
- submitted = []
19
17
  })
20
18
  afterEach(() => rmSync(root, { recursive: true, force: true }))
21
19
 
22
- function spool(content: Buffer) {
23
- const path = join(root, 'spool')
24
- writeFileSync(path, content)
25
- return { path, size: content.length }
26
- }
27
-
28
- // The runtime reads the input file while it answers bg.submit.
29
- function submit(params: unknown) {
30
- const p = params as { input: string }
31
- submitted.push({ ...p, content: readFileSync(p.input, 'utf8') })
32
- return JOB
33
- }
34
-
35
- test('hash.submit hashes a copy of the caller\'s file', async () => {
36
- const services = { 'files.read': () => spool(Buffer.from('hello')), 'bg.submit': submit }
37
- const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/report.txt' }, { services })
38
- assert.deepEqual(reply.data, JOB)
39
- assert.deepEqual(reply.services.map(([op]) => op), ['files.read', 'bg.submit'])
40
- assert.deepEqual(reply.services[0]![1], { path: '/home/report.txt' })
41
- const [job] = submitted as [{ key: string; name: string; content: string; input: string }]
42
- assert.deepEqual([job.key, job.name, job.content], ['k1', 'report.txt', 'hello'])
43
- assert.ok(job.input.startsWith(cfg.dataDir + sep), job.input)
44
- assert.equal(existsSync(job.input), false, 'the copy is removed once submitted')
45
- })
46
-
47
- test('hash.submit refuses a file over the job input limit', async () => {
48
- const services = { 'files.read': () => spool(Buffer.alloc(512 * 1024 + 1)), 'bg.submit': submit }
49
- const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/big.bin' }, { services })
50
- assert.equal(reply.error?.code, 'limit_exceeded')
51
- assert.deepEqual(submitted, [])
52
- })
53
-
54
- test('hash.submit passes on the runtime\'s refusal', async () => {
55
- const services = { 'files.read': () => { throw new Refusal('read_failed', 'Unable to read the file.') } }
56
- const reply = await call(build(cfg), 'hash.submit', { key: 'k1', path: '/home/a' }, { services })
57
- assert.equal(reply.error?.code, 'read_failed')
20
+ test('hash.submit passes imported input to the runtime', async () => {
21
+ const params = { key: 'k1', name: 'report.txt', input: join(cfg.dataDir, 'report.txt') }
22
+ const reply = await call(build(cfg), 'hash.submit', params, { services: { 'bg.submit': () => JOB } })
23
+ assert.deepEqual([reply.data, reply.services], [JOB, [['bg.submit', params]]])
24
+ assert.deepEqual(readdirSync(cfg.dataDir), [])
58
25
  })
59
26
 
60
27
  test('hash.submit refuses invalid params before any service', async () => {
61
- for (const params of [null, { key: 'k1' }, { key: 1, path: '/home/a' }, { key: 'k1', path: '/home/a', x: 1 }]) {
28
+ for (const params of [null, { key: 'k1' }, { key: 1, name: 'a', input: '/app/a' }, { key: 'k1', name: 'a', input: '/app/a', x: 1 }]) {
62
29
  const reply = await call(build(cfg), 'hash.submit', params)
63
30
  assert.deepEqual([reply.error?.code, reply.services], ['bad_request', []], JSON.stringify(params))
64
31
  }
@@ -108,7 +108,7 @@ rebuilding the SPK.
108
108
 
109
109
  | `--with` | Methods | Runtime services |
110
110
  | --- | --- | --- |
111
- | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `files.read` with the caller's session, `bg.submit`, `bg.get`, `bg.cancel` |
111
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
112
112
  | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
113
113
  | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
114
114
 
@@ -155,3 +155,13 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
155
155
  | Build hosts | macOS, Linux |
156
156
  | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
157
157
  | DSM | 7.1 or later declared (`os_min_ver`) |
158
+
159
+ ## Background job input
160
+
161
+ Jobs process an existing file imported by the App's producer into its private
162
+ data directory. The submission params are `{key, name, input}`: an idempotency
163
+ key, a display name and an absolute path inside that directory. The SDK keeps a
164
+ bounded private copy for the worker; no browser session or NAS picker is used.
165
+ Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
166
+ cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
167
+ services; the runtime checks the input path and binds ownership to the caller.
@@ -1,40 +1,8 @@
1
- """Background jobs: hash a NAS file the caller may read.
2
-
3
- The SDK's worker runs each job as the package account: it hashes the job's
4
- input (SHA-256) and keeps the result for the job's owner, the caller. The
5
- runtime serves files.read with the caller's DSM session and bg.* for the
6
- caller's own jobs only.
7
- """
8
-
9
- import os
10
- import shutil
11
- import tempfile
12
-
13
- from synopkgland_app import Error
14
-
1
+ """Background jobs over files already imported into the App data directory."""
15
2
  from params import fields
16
3
 
17
- # A job's input is at most 512 KiB, copied into the job when it is submitted.
18
- MAX_INPUT = 512 * 1024
19
-
20
4
 
21
5
  def register(app, cfg):
22
- app.method("hash.submit")(lambda ctx, params: submit(cfg, ctx, params))
6
+ app.method("hash.submit")(lambda ctx, params: ctx.runtime("bg.submit", fields(params, key=str, name=str, input=str)))
23
7
  app.method("hash.get")(lambda ctx, params: ctx.runtime("bg.get", fields(params, id=str)))
24
8
  app.method("hash.cancel")(lambda ctx, params: ctx.runtime("bg.cancel", fields(params, id=str)))
25
-
26
-
27
- def submit(cfg, ctx, params):
28
- p = fields(params, key=str, path=str)
29
- read = ctx.runtime("files.read", {"path": p["path"]})
30
- if read["size"] > MAX_INPUT:
31
- raise Error("limit_exceeded", "The file is larger than allowed.")
32
- # bg.submit takes its input from the data directory; the call's spool is elsewhere.
33
- inputs = os.path.join(cfg.data_dir, "hash-inputs")
34
- os.makedirs(inputs, mode=0o700, exist_ok=True)
35
- with open(read["path"], "rb") as source, tempfile.NamedTemporaryFile(dir=inputs, delete=False) as copy:
36
- shutil.copyfileobj(source, copy)
37
- try:
38
- return ctx.runtime("bg.submit", {"key": p["key"], "name": os.path.basename(p["path"]), "input": copy.name})
39
- finally:
40
- os.unlink(copy.name)
@@ -2,7 +2,7 @@ import os
2
2
  import tempfile
3
3
  import unittest
4
4
 
5
- from support import Error, call
5
+ from support import call
6
6
 
7
7
  import routes
8
8
 
@@ -16,46 +16,15 @@ class JobsTest(unittest.TestCase):
16
16
  self.data = os.path.join(self.dir.name, "var")
17
17
  os.mkdir(self.data)
18
18
  self.app = routes.build(routes.Config("ExampleApp", self.data))
19
- self.submitted = []
20
19
 
21
- def spool(self, content):
22
- path = os.path.join(self.dir.name, "spool")
23
- with open(path, "wb") as f:
24
- f.write(content)
25
- return {"path": path, "size": len(content)}
26
-
27
- def submit(self, params):
28
- # The runtime reads the input file while it answers bg.submit.
29
- with open(params["input"], "rb") as f:
30
- self.submitted.append(dict(params, content=f.read()))
31
- return JOB
32
-
33
- def test_submit_hashes_a_copy_of_the_callers_file(self):
34
- services = {"files.read": lambda p: self.spool(b"hello"), "bg.submit": self.submit}
35
- reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/report.txt"}, services=services)
36
- self.assertEqual(reply.data, JOB)
37
- self.assertEqual([op for op, _ in reply.services], ["files.read", "bg.submit"])
38
- self.assertEqual(reply.services[0][1], {"path": "/home/report.txt"})
39
- [submitted] = self.submitted
40
- self.assertEqual((submitted["key"], submitted["name"], submitted["content"]), ("k1", "report.txt", b"hello"))
41
- self.assertTrue(submitted["input"].startswith(self.data + os.sep))
42
- self.assertFalse(os.path.exists(submitted["input"]), "the copy is removed once submitted")
43
-
44
- def test_submit_refuses_a_file_over_the_job_input_limit(self):
45
- services = {"files.read": lambda p: self.spool(b"x" * (512 * 1024 + 1)), "bg.submit": self.submit}
46
- reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/big.bin"}, services=services)
47
- self.assertEqual(reply.error["code"], "limit_exceeded")
48
- self.assertEqual(self.submitted, [])
49
-
50
- def test_submit_passes_on_the_runtimes_refusal(self):
51
- def unreadable(params):
52
- raise Error("read_failed", "Unable to read the file.")
53
-
54
- reply = call(self.app, "hash.submit", {"key": "k1", "path": "/home/a"}, services={"files.read": unreadable})
55
- self.assertEqual(reply.error["code"], "read_failed")
20
+ def test_submit_passes_imported_input_to_the_runtime(self):
21
+ params = {"key": "k1", "name": "report.txt", "input": os.path.join(self.data, "report.txt")}
22
+ reply = call(self.app, "hash.submit", params, services={"bg.submit": lambda p: JOB})
23
+ self.assertEqual((reply.data, reply.services), (JOB, [("bg.submit", params)]))
24
+ self.assertEqual(os.listdir(self.data), [])
56
25
 
57
26
  def test_submit_refuses_invalid_params_before_any_service(self):
58
- for params in [None, {"key": "k1"}, {"key": 1, "path": "/home/a"}, {"key": "k1", "path": "/home/a", "x": 1}]:
27
+ for params in [None, {"key": "k1"}, {"key": 1, "name": "a", "input": "/app/a"}, {"key": "k1", "name": "a", "input": "/app/a", "x": 1}]:
59
28
  with self.subTest(params=params):
60
29
  reply = call(self.app, "hash.submit", params)
61
30
  self.assertEqual((reply.error["code"], reply.services), ("bad_request", []))
@@ -117,7 +117,7 @@ rebuilding the SPK.
117
117
 
118
118
  | `--with` | Methods | Runtime services |
119
119
  | --- | --- | --- |
120
- | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | `files.read` with the caller's session, `bg.submit`, `bg.get`, `bg.cancel` |
120
+ | `jobs` | `hash.submit`, `hash.get`, `hash.cancel` | Submit an App-owned imported file with `{key, name, input}` via `bg.submit`; look up or cancel the caller's job via `bg.get` / `bg.cancel` |
121
121
  | `root-ops` | `whoami.run` | `root.run` of `root/whoami.sh`, once an administrator approves the executor |
122
122
  | `delegate` | `report.save` | `delegate.move_from_staging` into a folder the caller chose |
123
123
 
@@ -164,3 +164,13 @@ ticks **Also delete the app data listed above** in the uninstall wizard.
164
164
  | Build hosts | macOS, Linux |
165
165
  | Target CPUs | x86_64 and aarch64 DSM models (one SPK each); armv7 is opt-in in `arches`, built but not NAS-verified |
166
166
  | DSM | 7.1 or later declared (`os_min_ver`) |
167
+
168
+ ## Background job input
169
+
170
+ Jobs process an existing file imported by the App's producer into its private
171
+ data directory. The submission params are `{key, name, input}`: an idempotency
172
+ key, a display name and an absolute path inside that directory. The SDK keeps a
173
+ bounded private copy for the worker; no browser session or NAS picker is used.
174
+ Submit with `hash.submit`; use `hash.get` for progress and `hash.cancel` to
175
+ cancel. These handlers call the existing `bg.submit`, `bg.get` and `bg.cancel`
176
+ services; the runtime checks the input path and binds ownership to the caller.
@@ -1,90 +1,41 @@
1
- //! Background jobs: hash a NAS file the caller may read.
2
- //!
3
- //! The SDK's worker runs each job as the package account: it hashes the job's
4
- //! input (SHA-256) and keeps the result for the job's owner, the caller. The
5
- //! runtime serves files.read with the caller's DSM session and bg.* for the
6
- //! caller's own jobs only.
7
-
8
- use crate::routes::{Config, internal};
1
+ //! Background jobs over files already imported into the App data directory.
2
+ use crate::routes::Config;
9
3
  use serde::Deserialize;
10
4
  use serde_json::{Value, json};
11
- use std::os::unix::fs::DirBuilderExt;
12
- use std::path::{Path, PathBuf};
13
5
  use syno_pkg_land_app::{App, Context, Error, Params};
14
6
 
15
- /// A job's input is at most 512 KiB, copied into the job when it is submitted.
16
- const MAX_INPUT: u64 = 512 * 1024;
17
-
18
7
  #[derive(Deserialize)]
19
8
  #[serde(deny_unknown_fields)]
20
9
  struct Submit {
21
10
  key: String,
22
- path: String,
11
+ name: String,
12
+ input: String,
23
13
  }
24
-
25
14
  #[derive(Deserialize)]
26
15
  #[serde(deny_unknown_fields)]
27
16
  struct JobId {
28
17
  id: String,
29
18
  }
30
19
 
31
- #[derive(Deserialize)]
32
- struct Read {
33
- path: PathBuf,
34
- size: u64,
35
- }
36
-
37
- pub fn register(app: &mut App, cfg: &Config) {
38
- let cfg = cfg.clone();
39
- app.method("hash.submit", move |ctx, params| submit(&cfg, ctx, params));
20
+ pub fn register(app: &mut App, _cfg: &Config) {
21
+ app.method("hash.submit", |ctx, params| -> Result<Value, Error> {
22
+ let Submit { key, name, input } = params.decode()?;
23
+ ctx.runtime(
24
+ "bg.submit",
25
+ &json!({"key": key, "name": name, "input": input}),
26
+ )
27
+ });
40
28
  app.method("hash.get", |ctx, params| job(ctx, "bg.get", params));
41
29
  app.method("hash.cancel", |ctx, params| job(ctx, "bg.cancel", params));
42
30
  }
43
-
44
31
  fn job(ctx: &Context, op: &str, params: &Params) -> Result<Value, Error> {
45
32
  let JobId { id } = params.decode()?;
46
33
  ctx.runtime(op, &json!({"id": id}))
47
34
  }
48
-
49
- fn submit(cfg: &Config, ctx: &Context, params: &Params) -> Result<Value, Error> {
50
- let p: Submit = params.decode()?;
51
- let read: Read = ctx.runtime("files.read", &json!({"path": p.path}))?;
52
- if read.size > MAX_INPUT {
53
- return Err(Error::new(
54
- "limit_exceeded",
55
- "The file is larger than allowed.",
56
- ));
57
- }
58
- // bg.submit takes its input from the data directory; the call's spool is elsewhere.
59
- let inputs = cfg.data_dir.join("hash-inputs");
60
- std::fs::DirBuilder::new()
61
- .recursive(true)
62
- .mode(0o700)
63
- .create(&inputs)
64
- .map_err(internal)?;
65
- let copy = inputs.join(&ctx.request_id);
66
- std::fs::copy(&read.path, &copy).map_err(internal)?;
67
- let name = Path::new(&p.path)
68
- .file_name()
69
- .map_or(String::new(), |n| n.to_string_lossy().into_owned());
70
- let submitted = ctx.runtime(
71
- "bg.submit",
72
- &json!({"key": p.key, "name": name, "input": copy}),
73
- );
74
- let _ = std::fs::remove_file(&copy);
75
- submitted
76
- }
77
-
78
35
  #[cfg(test)]
79
36
  mod tests {
80
37
  use super::*;
81
38
  use crate::test_support::{Services, TempDir, call};
82
- use serde_json::{Value, json};
83
- use std::sync::{Arc, Mutex};
84
-
85
- /// What bg.submit was asked: its params and the input it read.
86
- type Submitted = Arc<Mutex<Vec<(Value, Vec<u8>)>>>;
87
-
88
39
  fn setup() -> (TempDir, Config) {
89
40
  let dir = TempDir::new();
90
41
  let cfg = Config {
@@ -100,102 +51,30 @@ mod tests {
100
51
  json!({"job": {"id": "j1", "state": "queued"}, "duplicate": false})
101
52
  }
102
53
 
103
- /// files.read answers a spool file with content; bg.submit records its params and the input it read.
104
- fn services(dir: &TempDir, content: Vec<u8>, submitted: &Submitted) -> Services {
105
- let spool = dir.path().join("spool");
106
- std::fs::write(&spool, &content).unwrap();
107
- let read = json!({"path": spool, "size": content.len()});
108
- let submitted = submitted.clone();
109
- [
110
- (
111
- "files.read",
112
- Box::new(move |_: &Value| Ok(read.clone())) as _,
113
- ),
114
- (
115
- "bg.submit",
116
- Box::new(move |p: &Value| {
117
- // The runtime reads the input file while it answers bg.submit.
118
- let input = std::fs::read(p["input"].as_str().unwrap()).unwrap();
119
- submitted.lock().unwrap().push((p.clone(), input));
120
- Ok(job())
121
- }) as _,
122
- ),
123
- ]
124
- .into()
125
- }
126
-
127
54
  #[test]
128
- fn submit_hashes_a_copy_of_the_callers_file() {
129
- let (dir, cfg) = setup();
130
- let submitted = Arc::new(Mutex::new(Vec::new()));
131
- let services = services(&dir, b"hello".to_vec(), &submitted);
132
- let reply = call(
133
- crate::routes::build(cfg.clone()),
134
- "hash.submit",
135
- json!({"key": "k1", "path": "/home/report.txt"}),
136
- "alice",
137
- &services,
138
- );
139
- assert_eq!(reply.data, job());
140
- let ops: Vec<_> = reply.services.iter().map(|(op, _)| op.as_str()).collect();
141
- assert_eq!(ops, ["files.read", "bg.submit"]);
142
- assert_eq!(reply.services[0].1, json!({"path": "/home/report.txt"}));
143
- let submitted = submitted.lock().unwrap();
144
- let (params, content) = &submitted[0];
145
- assert_eq!(
146
- (&params["key"], &params["name"], content.as_slice()),
147
- (&json!("k1"), &json!("report.txt"), b"hello".as_slice())
148
- );
149
- let input = params["input"].as_str().unwrap();
150
- assert!(input.starts_with(cfg.data_dir.to_str().unwrap()), "{input}");
151
- assert!(
152
- !std::path::Path::new(input).exists(),
153
- "the copy is removed once submitted"
154
- );
155
- }
156
-
157
- #[test]
158
- fn submit_refuses_a_file_over_the_job_input_limit() {
159
- let (dir, cfg) = setup();
160
- let submitted = Arc::new(Mutex::new(Vec::new()));
161
- let services = services(&dir, vec![0; MAX_INPUT as usize + 1], &submitted);
162
- let reply = call(
163
- crate::routes::build(cfg),
164
- "hash.submit",
165
- json!({"key": "k1", "path": "/home/big.bin"}),
166
- "alice",
167
- &services,
168
- );
169
- assert_eq!(reply.code(), "limit_exceeded");
170
- assert!(submitted.lock().unwrap().is_empty());
171
- }
172
-
173
- #[test]
174
- fn submit_passes_on_the_runtimes_refusal() {
55
+ fn submit_passes_imported_input_to_the_runtime() {
175
56
  let (_dir, cfg) = setup();
176
- let services: Services = [(
177
- "files.read",
178
- Box::new(|_: &Value| Err(Error::new("read_failed", "Unable to read the file."))) as _,
179
- )]
180
- .into();
57
+ let params =
58
+ json!({"key":"k1","name":"report.txt","input":cfg.data_dir.join("report.txt")});
59
+ let services: Services = [("bg.submit", Box::new(|_: &Value| Ok(job())) as _)].into();
181
60
  let reply = call(
182
61
  crate::routes::build(cfg),
183
62
  "hash.submit",
184
- json!({"key": "k1", "path": "/home/a"}),
63
+ params.clone(),
185
64
  "alice",
186
65
  &services,
187
66
  );
188
- assert_eq!(reply.code(), "read_failed");
67
+ assert_eq!(reply.data, job());
68
+ assert_eq!(reply.services, vec![("bg.submit".to_string(), params)]);
189
69
  }
190
-
191
70
  #[test]
192
71
  fn submit_refuses_invalid_params_before_any_service() {
193
72
  let (_dir, cfg) = setup();
194
73
  for params in [
195
74
  Value::Null,
196
75
  json!({"key": "k1"}),
197
- json!({"key": 1, "path": "/home/a"}),
198
- json!({"key": "k1", "path": "/home/a", "x": 1}),
76
+ json!({"key": 1, "name": "a", "input": "/app/a"}),
77
+ json!({"key": "k1", "name": "a", "input": "/app/a", "x": 1}),
199
78
  ] {
200
79
  let reply = call(
201
80
  crate::routes::build(cfg.clone()),
@@ -1,59 +0,0 @@
1
- // @vitest-environment jsdom
2
- import {expect,test,vi} from 'vitest'
3
- import {nextTick} from 'vue'
4
- import {mount} from '../src/main'
5
- import {dsmWindow} from './context'
6
- async function settle(){for(let i=0;i<20;i++){await Promise.resolve();await nextTick()}}
7
- // The CLI turns the background page on for jobs (and delegate) projects only.
8
- test('a jobs project mounts the background panel for the signed-in user',async()=>{
9
- const call=vi.fn(async(method:string)=>{
10
- if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
11
- throw {code:'unknown_method'}
12
- })
13
- const root=document.createElement('div');document.body.append(root)
14
- const app=mount(root,dsmWindow({call}))
15
- try{
16
- await settle()
17
- expect(call.mock.calls.some(([method])=>method.startsWith('bg.'))).toBe(true)
18
- expect(root.querySelector('[data-page="background"]')).not.toBeNull()
19
- }finally{app.unmount();root.remove()}
20
- })
21
- test('the background panel pauses a running job and resumes a paused one',async()=>{
22
- const submitter={user:'alice',uid:'1024'}
23
- const jobs=[{id:'run',name:'a.bin',state:'running',submitter},{id:'held',name:'b.bin',state:'paused',pauseReason:'user',submitter}]
24
- const call=vi.fn(async(method:string):Promise<unknown>=>{
25
- if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
26
- if(method==='bg.status')return {owner:submitter,admin:false,revision:1,worker:{username:'pkg',alive:true},limits:{maxInputBytes:1024,maxQueued:8,maxOwnerPending:4,maxOwnerBytes:4096,jobTimeoutSeconds:60}}
27
- if(method==='bg.jobs.list')return {jobs}
28
- if(method==='bg.schedules.list')return {schedules:[]}
29
- return {}
30
- })
31
- const root=document.createElement('div');document.body.append(root)
32
- const app=mount(root,dsmWindow({call}))
33
- const button=(id:string,label:string)=>Array.from(root.querySelectorAll(`[data-job-id="${id}"] button`)).find(b=>b.textContent===label) as HTMLButtonElement|undefined
34
- try{
35
- await settle()
36
- button('run','Pause')!.click();await settle()
37
- expect(call).toHaveBeenCalledWith('bg.jobs.pause',{id:'run'})
38
- button('held','Resume')!.click();await settle()
39
- expect(call).toHaveBeenCalledWith('bg.jobs.resume',{id:'held'})
40
- }finally{app.unmount();root.remove()}
41
- })
42
- test('the header Refresh reloads the background panel',async()=>{
43
- const call=vi.fn(async(method:string):Promise<unknown>=>{
44
- if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
45
- if(method==='bg.status')return {owner:{user:'alice',uid:'1024'},admin:false,revision:1,worker:{username:'pkg',alive:true},limits:{maxInputBytes:1024,maxQueued:8,maxOwnerPending:4,maxOwnerBytes:4096,jobTimeoutSeconds:60}}
46
- if(method==='bg.jobs.list')return {jobs:[]}
47
- if(method==='bg.schedules.list')return {schedules:[]}
48
- return {}
49
- })
50
- const root=document.createElement('div');document.body.append(root)
51
- const app=mount(root,dsmWindow({call}))
52
- const reads=()=>call.mock.calls.filter(([method])=>method==='bg.jobs.list').length
53
- try{
54
- await settle();const before=reads()
55
- // The header button comes first; the panels below may have their own.
56
- ;(Array.from(root.querySelectorAll('button')).find(b=>b.textContent==='Refresh') as HTMLButtonElement).click();await settle()
57
- expect(reads()).toBeGreaterThan(before)
58
- }finally{app.unmount();root.remove()}
59
- })