@petercjl/topazlabscli 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -15,7 +15,7 @@ npm install --global @petercjl/topazlabscli
15
15
  topazlabscli skill install --agent all
16
16
  ```
17
17
 
18
- The CLI checks npm for a newer stable release before operational commands, at most once every six hours. It first uses the registry already configured for npm and automatically tries `https://registry.npmmirror.com/` if that registry is unavailable. The successful registry is used to download the complete update tarball before the installed version is touched, without changing the user's `.npmrc`. When an update is available the CLI upgrades itself from that local tarball, refreshes installed Agent Skills, and then resumes the original command. It discovers npm through the running Node installation, preserves SealSeek's managed global prefix/cache, and discovers Windows OpenSSH through the standard system location, so it also works in Agent runtimes with a restricted `PATH`. A temporary registry outage does not block video processing. `topazlabscli update` forces an immediate manual update.
18
+ The CLI checks npm for a newer stable release before operational commands, at most once every six hours. It first uses the registry already configured for npm and automatically tries `https://registry.npmmirror.com/` if that registry is unavailable. The successful registry is used to download the exact discovered version as a complete tarball before the installed version is touched, without changing the user's `.npmrc`. When an update is available the CLI upgrades itself from that local tarball, refreshes installed Agent Skills, and then resumes the original command. It discovers npm through the running Node installation, preserves SealSeek's managed global prefix/cache, and discovers Windows OpenSSH through the standard system location, so it also works in Agent runtimes with a restricted `PATH`. A temporary registry outage does not block video processing. `topazlabscli update` forces an immediate manual update.
19
19
 
20
20
  On Windows, SealSeek Skills are installed into `%USERPROFILE%\.sealseek\workspace\skills` when that workspace is present. The CLI automatically uses a managed copy because SealSeek rejects junctions that resolve outside the workspace Skill root; subsequent CLI updates refresh the copy from the npm package. The copy includes a local runtime manifest so the Agent can invoke the canonical package even when its PATH is restricted. `SEALSEEK_SKILLS_HOME` remains available as an explicit override.
21
21
 
@@ -55,7 +55,7 @@ topazlabscli doctor --json
55
55
  topazlabscli model status --json
56
56
  ```
57
57
 
58
- The worker uses a global Windows mutex and one queue consumer, so jobs from multiple clients run serially.
58
+ The CLI automatically upgrades the remote worker when the bundled worker version changes. `process` and `job wait` keep the queue runner attached to the active SSH command, while a global mutex and one queue consumer serialize jobs from multiple clients. This avoids Windows Agent runtimes terminating a detached SSH child. An abandoned `running` record is converted to a terminal `WORKER_LOST` failure instead of waiting forever.
59
59
 
60
60
  ## Process a video
61
61
 
@@ -74,7 +74,7 @@ topazlabscli job wait JOB_ID --json
74
74
  topazlabscli job download JOB_ID --output .\output-1080p.mp4 --json
75
75
  ```
76
76
 
77
- Version 0.2 includes one preset: `seedance-human-1080p`, using Proteus v4 (`prob-4`), source FPS, aspect-preserving 1080p output, and NVIDIA H.264 encoding.
77
+ Version 0.2 includes one preset: `seedance-human-1080p`, using Proteus v4 (`prob-4`), source FPS, aspect-preserving 1080p output, and NVIDIA H.264 encoding. The client reads MP4 track dimensions before upload so the worker does not depend on launching Topaz's bundled FFprobe through a remote shell.
78
78
 
79
79
  ## Configuration
80
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@petercjl/topazlabscli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Cross-Agent CLI and portable Skill for queued remote Topaz Video AI processing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "test": "node --test",
22
- "check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && node --check src/runtime.mjs && node --check src/update.mjs && npm test"
22
+ "check": "node --check bin/topazlabscli.mjs && node --check src/cli.mjs && node --check src/media.mjs && node --check src/runtime.mjs && node --check src/ssh.mjs && node --check src/update.mjs && npm test"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public",
@@ -32,6 +32,7 @@ Use the CLI as the single execution surface. Do not reproduce SSH, SFTP, queue,
32
32
  - Connection failure: report `CONNECTION_FAILED` and the endpoint attempts. The CLI does not start, reconfigure, or grant access to a VPN.
33
33
  - Worker/model not ready: run `worker status` or `model status`; stop with the returned dependency error. Installing/licensing Topaz and downloading models remain GUI administration tasks.
34
34
  - Long-running work: use `job submit`, return the job ID, then `job wait` when the user asks to remain attached. Do not resubmit merely because a wait timed out.
35
+ - Remote runner loss: `process` and `job wait` keep the queue runner attached through the active SSH command instead of relying on a detached client process. `job status` and `job wait` convert an abandoned `running` record into a terminal `WORKER_LOST` failure. Report it and resubmit only when the user requests another processing attempt.
35
36
  - Cancellation: queued work may cancel immediately. A running task records a cancellation request but is not forcibly killed in version 0.2.
36
37
  - Missing capability: return `CAPABILITY_UNAVAILABLE` or the CLI's structured error. Do not invent a platform-specific workaround.
37
38
 
@@ -39,7 +40,9 @@ Use the CLI as the single execution surface. Do not reproduce SSH, SFTP, queue,
39
40
 
40
41
  Configuration, hostnames, addresses, usernames, SSH identities, VPN details, media, Topaz binaries, models, and credentials are external to this Skill and npm package. Installation does not grant access to a workstation. Treat the configured server and Topaz license as user-managed resources.
41
42
 
42
- Before operational commands, the CLI performs a cached npm update check. It tries the user's current npm registry and then its built-in reachable-registry fallback without changing the user's global npm configuration. A newer stable package is fully downloaded from the registry that answered the version check before the installed version is touched, then installed Agent Skills are refreshed and the original command resumes under the new version. The CLI resolves npm through the running Agent's Node installation when PATH is restricted. Registry, npm, and Skill-refresh failures produce a warning and continue with the installed version. Treat `doctor`'s `updates.registry` check as advisory; a failed update source does not make video processing unavailable.
43
+ Before submitting a job, the CLI compares the remote worker version with its bundled worker and upgrades the remote worker when required. Queue execution stays attached to the invoking CLI command; the Agent must wait for the command's terminal JSON result and must not background or abandon it.
44
+
45
+ Before operational commands, the CLI performs a cached npm update check. It tries the user's current npm registry and then its built-in reachable-registry fallback without changing the user's global npm configuration. The exact version returned by that check is fully downloaded from the same registry before the installed version is touched, then installed Agent Skills are refreshed and the original command resumes under the new version. The CLI resolves npm through the running Agent's Node installation when PATH is restricted. Registry, npm, and Skill-refresh failures produce a warning and continue with the installed version. Treat `doctor`'s `updates.registry` check as advisory; a failed update source does not make video processing unavailable.
43
46
 
44
47
  Do not overwrite a local output unless the user has authorized that exact existing target. The remote worker retains job inputs, outputs, status, and logs for operator review; cleanup is an administrative action outside version 0.2.
45
48
 
package/src/cli.mjs CHANGED
@@ -6,13 +6,15 @@ import { loadConfig, saveConfig, resolveTarget } from "./config.mjs";
6
6
  import { configPath, workerScript } from "./paths.mjs";
7
7
  import { CliError, requireValue } from "./errors.mjs";
8
8
  import { run } from "./process.mjs";
9
- import { psLiteral, runPowerShell, selectEndpoint, sftpGet, sftpPut, startPowerShellDetached } from "./ssh.mjs";
9
+ import { probeMp4Dimensions } from "./media.mjs";
10
+ import { psLiteral, runPowerShell, selectEndpoint, sftpGet, sftpPut } from "./ssh.mjs";
10
11
  import { skillInstall, skillSource, skillStatus } from "./skill.mjs";
11
12
  import { installLatestPackage, maybeAutoUpdate, queryLatestVersion, updateRegistryWarning } from "./update.mjs";
12
13
 
13
14
  const require = createRequire(import.meta.url);
14
15
  const pkg = require("../package.json");
15
16
  const PRESET = "seedance-human-1080p";
17
+ const WORKER_VERSION = "0.2.0";
16
18
 
17
19
  const CAPABILITIES = {
18
20
  schema_version: 1,
@@ -99,10 +101,10 @@ function parseRemoteJson(result, code = "REMOTE_ERROR") {
99
101
  catch { throw new CliError("REMOTE_OUTPUT_INVALID", "Remote worker did not return valid JSON.", { output: text }); }
100
102
  }
101
103
 
102
- async function remoteAction(target, endpoint, action, params = {}) {
104
+ async function remoteAction(target, endpoint, action, params = {}, { timeout = 7 } = {}) {
103
105
  const encoded = Buffer.from(JSON.stringify(params), "utf8").toString("base64");
104
106
  const extra = Object.keys(params).length ? `-PayloadBase64 ${psLiteral(encoded)}` : "";
105
- return parseRemoteJson(await runPowerShell(target, endpoint, workerInvocation(remoteRoot(target), action, extra)));
107
+ return parseRemoteJson(await runPowerShell(target, endpoint, workerInvocation(remoteRoot(target), action, extra), { timeout }));
106
108
  }
107
109
 
108
110
  async function getConnectedTarget(requested) {
@@ -111,18 +113,44 @@ async function getConnectedTarget(requested) {
111
113
  return { target, ...selected };
112
114
  }
113
115
 
116
+ async function installWorker(target, endpoint) {
117
+ const root = remoteRoot(target);
118
+ const remote = `${root}\\.topazlabscli\\worker\\topazlabs-worker.ps1`;
119
+ const workerDirectory = `${root}\\.topazlabscli\\worker`;
120
+ const prep = `$p=${psLiteral(workerDirectory)}; New-Item -ItemType Directory -Force -Path $p | Out-Null; [Console]::Out.Write('{"ok":true}')`;
121
+ parseRemoteJson(await runPowerShell(target, endpoint, prep));
122
+ await sftpPut(target, endpoint, workerScript, remote);
123
+ return remoteAction(target, endpoint, "Install");
124
+ }
125
+
126
+ async function ensureWorker(target, endpoint) {
127
+ let status = null;
128
+ try { status = await remoteAction(target, endpoint, "Status"); }
129
+ catch { /* Install or repair the worker below. */ }
130
+ if (!status?.installed || status.worker_version !== WORKER_VERSION) return installWorker(target, endpoint);
131
+ return status;
132
+ }
133
+
114
134
  async function submitJob(inputPath, requestedTarget, preset = PRESET) {
115
135
  const input = path.resolve(inputPath);
116
136
  if (!fs.existsSync(input) || !fs.statSync(input).isFile()) throw new CliError("INPUT_NOT_FOUND", `Video not found: ${input}`);
117
137
  if (preset !== PRESET) throw new CliError("PRESET_UNSUPPORTED", `Unsupported preset: ${preset}`);
138
+ let source;
139
+ try { source = probeMp4Dimensions(input); }
140
+ catch (error) { throw new CliError("INPUT_METADATA_UNSUPPORTED", `Could not read MP4 video dimensions: ${error.message}`); }
118
141
  const { target, endpoint, attempts } = await getConnectedTarget(requestedTarget);
119
142
  const id = `${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`;
120
143
  const safeName = path.basename(input).replace(/[^A-Za-z0-9._-]/g, "_");
144
+ const worker = await ensureWorker(target, endpoint);
121
145
  const prepared = await remoteAction(target, endpoint, "Prepare", { id, input_name: safeName });
122
146
  await sftpPut(target, endpoint, input, prepared.input_path);
123
- const job = await remoteAction(target, endpoint, "Enqueue", { id, input_name: safeName, preset });
124
- const runnerPid = startPowerShellDetached(target, endpoint, workerInvocation(remoteRoot(target), "Run"));
125
- return { ...job, target: target.name, endpoint: endpoint.name, runner_pid: runnerPid, connection_attempts: attempts };
147
+ const job = await remoteAction(target, endpoint, "Enqueue", { id, input_name: safeName, preset, source_width: source.width, source_height: source.height });
148
+ return { ...job, target: target.name, endpoint: endpoint.name, worker_version: worker.worker_version, connection_attempts: attempts };
149
+ }
150
+
151
+ async function runQueue(requestedTarget, timeoutSeconds = 86400) {
152
+ const { target, endpoint } = await getConnectedTarget(requestedTarget);
153
+ return { ...(await remoteAction(target, endpoint, "Run", {}, { timeout: timeoutSeconds })), target: target.name, endpoint: endpoint.name };
126
154
  }
127
155
 
128
156
  async function waitJob(id, requestedTarget, intervalSeconds = 10, timeoutSeconds = 86400) {
@@ -271,13 +299,7 @@ export async function main(rawArgs) {
271
299
  const requested = option(args, "--target");
272
300
  const { target, endpoint, attempts } = await getConnectedTarget(requested);
273
301
  if (action === "install") {
274
- const root = remoteRoot(target);
275
- const remote = `${root}\\.topazlabscli\\worker\\topazlabs-worker.ps1`;
276
- const workerDirectory = `${root}\\.topazlabscli\\worker`;
277
- const prep = `$p=${psLiteral(workerDirectory)}; New-Item -ItemType Directory -Force -Path $p | Out-Null; [Console]::Out.Write('{"ok":true}')`;
278
- parseRemoteJson(await runPowerShell(target, endpoint, prep));
279
- await sftpPut(target, endpoint, workerScript, remote);
280
- const installed = await remoteAction(target, endpoint, "Install");
302
+ const installed = await installWorker(target, endpoint);
281
303
  return output({ ...installed, target: target.name, endpoint: endpoint.name, connection_attempts: attempts }, json);
282
304
  }
283
305
  if (action === "status") return output(await remoteAction(target, endpoint, "Status"), json);
@@ -303,7 +325,12 @@ export async function main(rawArgs) {
303
325
  const { target, endpoint } = await getConnectedTarget(requested);
304
326
  return output(await remoteAction(target, endpoint, "JobStatus", { id }), json);
305
327
  }
306
- if (action === "wait") return output(await waitJob(id, requested, Number(option(args, "--interval") || 10), Number(option(args, "--timeout") || 86400)), json);
328
+ if (action === "wait") {
329
+ const timeout = Number(option(args, "--timeout") || 86400);
330
+ const interval = Number(option(args, "--interval") || 10);
331
+ await runQueue(requested, timeout);
332
+ return output(await waitJob(id, requested, interval, timeout), json);
333
+ }
307
334
  if (action === "download") return output(await downloadJob(id, requested, option(args, "--output")), json);
308
335
  if (action === "cancel") {
309
336
  const { target, endpoint } = await getConnectedTarget(requested);
@@ -316,10 +343,12 @@ export async function main(rawArgs) {
316
343
  const input = requireValue(args.shift(), "INPUT_REQUIRED", "process requires a video.");
317
344
  const destination = option(args, "--output") || defaultOutputPath(input);
318
345
  const requested = option(args, "--target");
346
+ const timeout = Number(option(args, "--timeout") || 86400);
319
347
  const submitted = await submitJob(input, requested, option(args, "--preset") || PRESET);
320
- const finished = await waitJob(submitted.id, requested, Number(option(args, "--interval") || 10), Number(option(args, "--timeout") || 86400));
348
+ const runner = await runQueue(requested, timeout);
349
+ const finished = await waitJob(submitted.id, requested, Number(option(args, "--interval") || 10), timeout);
321
350
  if (finished.state !== "completed") throw new CliError("JOB_FAILED", `Job ${submitted.id} ended as ${finished.state}.`, finished);
322
- return output({ submitted, finished, downloaded: await downloadJob(submitted.id, requested, destination) }, json);
351
+ return output({ submitted, runner, finished, downloaded: await downloadJob(submitted.id, requested, destination) }, json);
323
352
  }
324
353
 
325
354
  if (command === "skill") {
@@ -337,7 +366,7 @@ export async function main(rawArgs) {
337
366
  const query = await queryLatestVersion(pkg, config);
338
367
  if (!query.ok) throw new CliError("UPDATE_FAILED", updateRegistryWarning(query.attempts), { attempts: query.attempts });
339
368
  const previousSkills = skillStatus("all");
340
- const result = await installLatestPackage(pkg, query.registry);
369
+ const result = await installLatestPackage(pkg, query.latest, query.registry);
341
370
  if (result.code !== 0) throw new CliError("UPDATE_FAILED", result.stderr.trim() || "npm update failed.");
342
371
  const skills = [];
343
372
  for (const existing of previousSkills.filter((item) => item.installed)) {
package/src/media.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs";
2
+
3
+ function readAt(fd, position, length) {
4
+ const buffer = Buffer.alloc(length);
5
+ const count = fs.readSync(fd, buffer, 0, length, position);
6
+ if (count !== length) throw new Error("Unexpected end of media file.");
7
+ return buffer;
8
+ }
9
+
10
+ function boxes(fd, start, end) {
11
+ const items = [];
12
+ let position = start;
13
+ while (position + 8 <= end) {
14
+ const header = readAt(fd, position, 8);
15
+ let size = header.readUInt32BE(0);
16
+ const type = header.toString("ascii", 4, 8);
17
+ let headerSize = 8;
18
+ if (size === 1) {
19
+ size = Number(readAt(fd, position + 8, 8).readBigUInt64BE(0));
20
+ headerSize = 16;
21
+ } else if (size === 0) size = end - position;
22
+ if (!Number.isSafeInteger(size) || size < headerSize || position + size > end) break;
23
+ items.push({ type, start: position, dataStart: position + headerSize, end: position + size });
24
+ position += size;
25
+ }
26
+ return items;
27
+ }
28
+
29
+ export function probeMp4Dimensions(file) {
30
+ const fd = fs.openSync(file, "r");
31
+ try {
32
+ const size = fs.fstatSync(fd).size;
33
+ const moov = boxes(fd, 0, size).find((item) => item.type === "moov");
34
+ if (!moov) throw new Error("MP4 moov box was not found.");
35
+ for (const trak of boxes(fd, moov.dataStart, moov.end).filter((item) => item.type === "trak")) {
36
+ const tkhd = boxes(fd, trak.dataStart, trak.end).find((item) => item.type === "tkhd");
37
+ if (!tkhd || tkhd.end - tkhd.dataStart < 8) continue;
38
+ const dimensions = readAt(fd, tkhd.end - 8, 8);
39
+ const width = Math.round(dimensions.readUInt32BE(0) / 65536);
40
+ const height = Math.round(dimensions.readUInt32BE(4) / 65536);
41
+ if (width > 0 && height > 0) return { width, height };
42
+ }
43
+ throw new Error("A video track with dimensions was not found.");
44
+ } finally {
45
+ fs.closeSync(fd);
46
+ }
47
+ }
package/src/ssh.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { spawn } from "node:child_process";
4
3
  import { run } from "./process.mjs";
5
4
  import { resolveExecutable } from "./runtime.mjs";
6
5
  import { CliError } from "./errors.mjs";
@@ -20,22 +19,17 @@ export function encodePowerShell(script) {
20
19
  return Buffer.from(script, "utf16le").toString("base64");
21
20
  }
22
21
 
22
+ export function powerShellCommand(script) {
23
+ const encoded = encodePowerShell(script);
24
+ return `"&([scriptblock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encoded}'))))"`;
25
+ }
26
+
23
27
  export async function runPowerShell(target, endpoint, script, { timeout = 7 } = {}) {
24
28
  const args = [...commonArgs(target, endpoint, { timeout }), destination(target, endpoint),
25
- "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
29
+ "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", powerShellCommand(script)];
26
30
  return run("ssh", args, { timeoutMs: (timeout + 3) * 1000 });
27
31
  }
28
32
 
29
- export function startPowerShellDetached(target, endpoint, script) {
30
- const args = [...commonArgs(target, endpoint, { timeout: 10 }), destination(target, endpoint),
31
- "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodePowerShell(script)];
32
- const executable = resolveExecutable("ssh");
33
- const child = spawn(executable.command, [...executable.argsPrefix, ...args], { detached: true, stdio: "ignore", windowsHide: true });
34
- child.on("error", () => {});
35
- child.unref();
36
- return child.pid;
37
- }
38
-
39
33
  export async function selectEndpoint(target) {
40
34
  const attempts = [];
41
35
  for (const endpoint of target.endpoints) {
package/src/update.mjs CHANGED
@@ -116,12 +116,12 @@ function registryFailureMessage(attempts) {
116
116
  return `Unable to check npm for updates: ${attempts.map((item) => `${item.registry} (${item.detail})`).join("; ")}`;
117
117
  }
118
118
 
119
- export async function installLatestPackage(pkg, registry, dependencies = {}) {
119
+ export async function installLatestPackage(pkg, latest, registry, dependencies = {}) {
120
120
  const env = dependencies.env || process.env;
121
121
  const execute = dependencies.run || run;
122
122
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "topazlabscli-update-"));
123
123
  try {
124
- const packed = await execute("npm", ["pack", `${pkg.name}@latest`, "--json", "--pack-destination", temporary, "--registry", registry], {
124
+ const packed = await execute("npm", ["pack", `${pkg.name}@${latest}`, "--json", "--pack-destination", temporary, "--registry", registry], {
125
125
  env,
126
126
  timeoutMs: dependencies.timeoutMs || UPDATE_TIMEOUT_MS
127
127
  });
@@ -169,7 +169,7 @@ export async function maybeAutoUpdate(rawArgs, pkg, dependencies = {}) {
169
169
  const installedSkills = getSkillStatus("all").filter((item) => item.installed);
170
170
  let install;
171
171
  try {
172
- install = await installLatestPackage(pkg, registry, dependencies);
172
+ install = await installLatestPackage(pkg, latest, registry, dependencies);
173
173
  } catch (error) {
174
174
  return { checked: true, warning: `Automatic npm update failed: ${error.message}`, latest, registry };
175
175
  }
@@ -8,12 +8,13 @@ param(
8
8
  )
9
9
 
10
10
  $ErrorActionPreference = 'Stop'
11
- $WorkerVersion = '0.1.1'
11
+ $WorkerVersion = '0.2.0'
12
12
  $StateRoot = Join-Path $Root '.topazlabscli'
13
13
  $QueueRoot = Join-Path $StateRoot 'queue'
14
14
  $JobsRoot = Join-Path $StateRoot 'jobs'
15
15
  $WorkerRoot = Join-Path $StateRoot 'worker'
16
16
  $ConfigPath = Join-Path $StateRoot 'worker-config.json'
17
+ $RunnerStatePath = Join-Path $StateRoot 'runner.json'
17
18
 
18
19
  function Write-Json($Value) {
19
20
  [Console]::Out.Write(($Value | ConvertTo-Json -Depth 8 -Compress))
@@ -77,6 +78,34 @@ function Get-ModelStatus($Config) {
77
78
  }
78
79
  }
79
80
 
81
+ function Test-JobProcessActive([string]$Id) {
82
+ $escaped = [Regex]::Escape($Id)
83
+ return $null -ne (Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
84
+ $_.Name -match '^(ffmpeg|ffprobe)\.exe$' -and $_.CommandLine -match $escaped
85
+ } | Select-Object -First 1)
86
+ }
87
+
88
+ function Test-RunnerProcessActive {
89
+ if (-not (Test-Path -LiteralPath $RunnerStatePath)) { return $false }
90
+ try {
91
+ $runner = Get-Content -Raw -LiteralPath $RunnerStatePath | ConvertFrom-Json
92
+ $process = Get-Process -Id ([int]$runner.pid) -ErrorAction SilentlyContinue
93
+ return ($null -ne $process -and $process.ProcessName -eq 'powershell')
94
+ } catch { return $false }
95
+ }
96
+
97
+ function Repair-StaleJobs {
98
+ if (Test-RunnerProcessActive) { return }
99
+ Get-ChildItem -LiteralPath $JobsRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
100
+ $statusPath = Join-Path $_.FullName 'status.json'
101
+ if (-not (Test-Path -LiteralPath $statusPath)) { return }
102
+ $status = Get-Content -Raw -LiteralPath $statusPath | ConvertFrom-Json
103
+ if ($status.state -eq 'running' -and -not (Test-JobProcessActive ([string]$status.id))) {
104
+ Set-JobStatus ([string]$status.id) @{ state = 'failed'; completed_at = (Get-Date).ToUniversalTime().ToString('o'); error_code = 'WORKER_LOST'; error = 'The remote queue runner exited before the job completed. Resubmit the job.' } | Out-Null
105
+ }
106
+ }
107
+ }
108
+
80
109
  function Invoke-Job($Job, $Config) {
81
110
  $id = [string]$Job.id
82
111
  $jobDir = Job-Directory $id
@@ -89,11 +118,9 @@ function Invoke-Job($Job, $Config) {
89
118
  if (-not (Test-Path -LiteralPath $Config.ffmpeg)) { throw "Topaz ffmpeg is missing: $($Config.ffmpeg)" }
90
119
 
91
120
  Set-JobStatus $id @{ state = 'running'; started_at = (Get-Date).ToUniversalTime().ToString('o'); input_path = $inputPath; output_path = $outputPath; output_name = $outputName; preset = $Job.preset } | Out-Null
92
- $probeText = & $Config.ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of json -- $inputPath
93
- if ($LASTEXITCODE -ne 0) { throw 'ffprobe failed.' }
94
- $probe = $probeText | ConvertFrom-Json
95
- $width = [int]$probe.streams[0].width
96
- $height = [int]$probe.streams[0].height
121
+ $width = [int]$Job.source_width
122
+ $height = [int]$Job.source_height
123
+ if ($width -le 0 -or $height -le 0) { throw 'Source dimensions are missing from the queued job.' }
97
124
  if ($width -ge $height) {
98
125
  $targetHeight = 1080
99
126
  $targetWidth = [int](2 * [Math]::Round((1080.0 * $width / $height) / 2.0))
@@ -140,8 +167,9 @@ switch ($Action) {
140
167
  $current = Read-WorkerConfig
141
168
  $current.worker_version = $WorkerVersion
142
169
  $current | ConvertTo-Json | Set-Content -LiteralPath $ConfigPath -Encoding UTF8
170
+ Repair-StaleJobs
143
171
  $model = Get-ModelStatus $current
144
- Write-Json @{ ok = $true; installed = $true; worker_version = $WorkerVersion; root = $Root; model_ready = $model.model_ready }
172
+ Write-Json @{ ok = $true; installed = $true; worker_version = $WorkerVersion; root = $Root; runner = 'attached-ssh'; model_ready = $model.model_ready }
145
173
  }
146
174
  'Status' {
147
175
  $installed = Test-Path -LiteralPath $ConfigPath
@@ -178,12 +206,17 @@ switch ($Action) {
178
206
  'Run' {
179
207
  $created = $false
180
208
  $mutex = New-Object Threading.Mutex($true, 'Global\TopazLabsCliQueue', [ref]$created)
181
- if (-not $created) { exit 0 }
209
+ if (-not $created) { Write-Json @{ ok = $true; runner = 'attached-ssh'; state = 'already-running' }; exit 0 }
182
210
  try {
211
+ @{ pid = $PID; started_at = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json | Set-Content -LiteralPath $RunnerStatePath -Encoding UTF8
183
212
  $config = Read-WorkerConfig
184
213
  while ($true) {
185
214
  $next = Get-ChildItem -LiteralPath $QueueRoot -Filter '*.json' | Sort-Object CreationTimeUtc, Name | Select-Object -First 1
186
- if ($null -eq $next) { break }
215
+ if ($null -eq $next) {
216
+ Start-Sleep -Seconds 2
217
+ $next = Get-ChildItem -LiteralPath $QueueRoot -Filter '*.json' | Sort-Object CreationTimeUtc, Name | Select-Object -First 1
218
+ if ($null -eq $next) { break }
219
+ }
187
220
  $job = Get-Content -Raw -LiteralPath $next.FullName | ConvertFrom-Json
188
221
  Remove-Item -Force -LiteralPath $next.FullName
189
222
  $cancelPath = Join-Path (Job-Directory ([string]$job.id)) 'cancel.requested'
@@ -197,14 +230,17 @@ switch ($Action) {
197
230
  }
198
231
  }
199
232
  } finally {
233
+ Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $RunnerStatePath
200
234
  $mutex.ReleaseMutex()
201
235
  $mutex.Dispose()
202
236
  }
237
+ Write-Json @{ ok = $true; runner = 'attached-ssh'; state = 'idle' }
203
238
  }
204
239
  'JobStatus' {
205
240
  $payload = Read-Payload
206
241
  $path = Status-Path ([string]$payload.id)
207
242
  if (-not (Test-Path -LiteralPath $path)) { throw 'Job not found.' }
243
+ Repair-StaleJobs
208
244
  [Console]::Out.Write((Get-Content -Raw -LiteralPath $path).Trim())
209
245
  }
210
246
  'ListJobs' {