@andrian.yablonskyy/thub-agent 1.0.0
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 +139 -0
- package/config.json +6 -0
- package/eslint.config.js +97 -0
- package/package.json +23 -0
- package/src/cli.js +251 -0
- package/src/config.js +79 -0
- package/src/duration.js +33 -0
- package/src/streaming.js +118 -0
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @andrian.yablonskyy/thub-agent
|
|
2
|
+
|
|
3
|
+
The Agent CLI (`thub`) for [TestHub](https://github.com/andrianyablonskyy/thub) — a self-hosted job network that lets CI/CD pipelines and individual developers run firmware tests on real hardware or emulators in a private lab. `thub` is the single entry point for both: it's stateless, everything it knows comes from the [Coordinator](https://github.com/andrianyablonskyy/thub-coordinator) API, and it runs identically on a GitHub-hosted runner and a developer laptop — a developer reproducing a CI failure runs exactly the same command the pipeline runs.
|
|
4
|
+
|
|
5
|
+
See the [main TestHub repo](https://github.com/andrianyablonskyy/thub) for the full system architecture and how this fits with the Coordinator and the [Client](https://github.com/andrianyablonskyy/thub-client).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @andrian.yablonskyy/thub-agent
|
|
11
|
+
# or, one-off in CI:
|
|
12
|
+
npx -y @andrian.yablonskyy/thub-agent run --type sw --image "$IMAGE_URL" --tests "$TESTS_URL" --wait
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
Read from flags, then environment (`THUB_URL`, `THUB_TOKEN`, `THUB_GROUP`, `THUB_USER`), then `~/.config/thub/config.json`, then a bundled default. `url`/`token` are required by the time a command actually talks to the Coordinator; `group`/`user` are optional everywhere.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
thub config set url https://thub.example.com
|
|
21
|
+
thub config set token agt_...
|
|
22
|
+
thub config set group 548ae4ae-... # optional default --group
|
|
23
|
+
thub config set user "Your Name" # optional default --user
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
thub run [options] Submit a test job and follow its log
|
|
30
|
+
thub status <jobId> Show status; follow log if running, show artifacts if done
|
|
31
|
+
thub cancel <jobId> Cancel a job
|
|
32
|
+
thub resources List resources and their status
|
|
33
|
+
thub jobs [--mine] [--state <s>] List recent jobs
|
|
34
|
+
thub config set <key> <value> Save coordinator URL / token / default group / default user locally
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Key options for `thub run`:
|
|
38
|
+
|
|
39
|
+
| Option | Description |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `--type hw\|sw` | Required resource type. |
|
|
42
|
+
| `--board <name>` / `--label <l>` | Required labels (repeatable). |
|
|
43
|
+
| `--group <groupId>` | Restrict scheduling to resources that are members of this group. Falls back to `THUB_GROUP` / `thub config set group <id>`. |
|
|
44
|
+
| `--user <name>` | Free-text job owner — a label, not an identity. Falls back to `THUB_USER` / `thub config set user <name>`. |
|
|
45
|
+
| `--image <url>` | Firmware/build image URL, fetched by the Client. |
|
|
46
|
+
| `--sha256 <hex>` | Expected sha256 of `--image`; the Client verifies it before flashing/running. |
|
|
47
|
+
| `--tests <url>` / `--suite <name>` | Test package and suite. |
|
|
48
|
+
| `--arg <value>` | Extra argument passed through to `run-tests.sh` on the Client (repeatable). |
|
|
49
|
+
| `--timeout <dur>` | e.g. `30m`, default `30m`. |
|
|
50
|
+
| `--priority <n>` | 0–100; CI defaults to 50, CLI to 60 so a developer is not starved by a busy pipeline. |
|
|
51
|
+
| `--meta <key=value>` | Arbitrary metadata stored on the job (repeatable) — CI job ids, git coordinates, anything else worth attaching to the run. |
|
|
52
|
+
| `--source ci\|cli` | Override auto-detected job source (defaults to `ci` when `$GITHUB_ACTIONS=true`, else `cli`). |
|
|
53
|
+
| `--dry-run` | Exercise the full pipeline without the Client executing anything for real. |
|
|
54
|
+
| `--wait` | Do not detach on job end; exit with the job's verdict code (used in CI). |
|
|
55
|
+
| `--detach` | Print the job id and exit immediately. |
|
|
56
|
+
| `--json` | Machine-readable output. |
|
|
57
|
+
|
|
58
|
+
- `thub run` prints the **job ID**, then streams logs until Ctrl-C. Ctrl-C detaches; the job keeps running on the Client.
|
|
59
|
+
- `thub status <jobId>` prints the current state; if active it keeps streaming, if done it prints the verdict and artifact download links.
|
|
60
|
+
|
|
61
|
+
Exit codes make the Agent usable as a CI step:
|
|
62
|
+
|
|
63
|
+
| Code | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `0` | `PASSED` |
|
|
66
|
+
| `1` | `FAILED` |
|
|
67
|
+
| `2` | `ERROR`, `TIMEOUT`, or `LOST` |
|
|
68
|
+
| `3` | `CANCELED` |
|
|
69
|
+
| `4` | Usage, auth or connection error |
|
|
70
|
+
| `130` | Detached with Ctrl-C (job still running) |
|
|
71
|
+
|
|
72
|
+
## Examples
|
|
73
|
+
|
|
74
|
+
**Associate a CI/CD job id with the internal job id:**
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
thub run --type sw --image "$IMAGE_URL" --tests "$TESTS_URL" \
|
|
78
|
+
--meta ciJobId="$GITHUB_RUN_ID" --wait
|
|
79
|
+
thub status A-00123 --json | jq '.spec.meta.ciJobId'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Pass Git repo/branch/hash/tag to the Client** — these travel as `--meta key=value` and the Client exposes each one to `run-tests.sh` as `THUB_META_<KEY>` (`ciJobId` → `THUB_META_CI_JOB_ID`):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
thub run --type hw --board nucleo-f401re \
|
|
86
|
+
--image "$IMAGE_URL" --tests "$TESTS_URL" --suite smoke \
|
|
87
|
+
--meta repo=yourorg/firmware --meta branch=main --meta sha=a1b2c3d --wait
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Dry-run the pipeline** — proves the Coordinator↔Client plumbing works without real hardware, a real emulator image, or a reachable Artifactory:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
thub run --type sw --image https://does-not-exist.invalid/app.bin \
|
|
94
|
+
--tests https://does-not-exist.invalid/tests.tar.gz --suite smoke \
|
|
95
|
+
--dry-run --wait
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Run on a specific resource group only:**
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
thub run --type sw --image "$IMAGE_URL" --tests "$TESTS_URL" \
|
|
102
|
+
--group 548ae4ae-ac5b-401f-acaa-24bbe790e62d --wait
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Label a job with its owner** — purely informational, shows up on the dashboard, in `thub jobs`, and on the Client's own console:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
thub run --type hw --board nucleo-f401re \
|
|
109
|
+
--image "$IMAGE_URL" --tests "$TESTS_URL" --user "Your Name" --wait
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## GitHub Actions
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
test-sw:
|
|
116
|
+
needs: build
|
|
117
|
+
runs-on: ubuntu-latest
|
|
118
|
+
env:
|
|
119
|
+
THUB_URL: https://thub.example.com
|
|
120
|
+
THUB_TOKEN: ${{ secrets.THUB_AGENT_TOKEN }}
|
|
121
|
+
steps:
|
|
122
|
+
- run: |
|
|
123
|
+
npx -y @andrian.yablonskyy/thub-agent run --type sw \
|
|
124
|
+
--image "${{ needs.build.outputs.image_url }}" \
|
|
125
|
+
--tests "${{ needs.build.outputs.tests_url }}" --suite full --wait
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
If the GitHub job is canceled, the runner sends `SIGINT` to the Agent. In `--wait` mode (CI), that's treated as a cancel request (`POST /jobs/:id/cancel`) before exiting, so abandoned CI jobs don't hold hardware. In interactive mode, Ctrl-C only detaches.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm install
|
|
134
|
+
npm run lint
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Proprietary — see the header comment in each source file.
|
package/config.json
ADDED
package/eslint.config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file eslint.config.js
|
|
3
|
+
* @description ESLint flat config for @andrian.yablonskyy/thub-agent: stylistic/promise rules plus Node globals
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const stylistic = require('@stylistic/eslint-plugin'),
|
|
17
|
+
promise = require('eslint-plugin-promise'),
|
|
18
|
+
globals = require('globals');
|
|
19
|
+
|
|
20
|
+
module.exports = [
|
|
21
|
+
{ ignores: ['**/node_modules/**'] },
|
|
22
|
+
{
|
|
23
|
+
files: ['**/*.{js,cjs,mjs}'],
|
|
24
|
+
plugins: { '@stylistic': stylistic, promise },
|
|
25
|
+
languageOptions: {
|
|
26
|
+
ecmaVersion: 'latest',
|
|
27
|
+
sourceType: 'commonjs',
|
|
28
|
+
globals: { ...globals.node }
|
|
29
|
+
},
|
|
30
|
+
rules: {
|
|
31
|
+
// Core JS
|
|
32
|
+
'prefer-const': 'error',
|
|
33
|
+
strict: ['error', 'global'],
|
|
34
|
+
camelcase: [
|
|
35
|
+
'error',
|
|
36
|
+
{
|
|
37
|
+
properties: 'never',
|
|
38
|
+
ignoreGlobals: true,
|
|
39
|
+
ignoreDestructuring: true,
|
|
40
|
+
allow: ['[a-z]+([a-z0-9_])?']
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
'one-var': ['error', { var: 'never', let: 'consecutive', const: 'consecutive' }],
|
|
44
|
+
'no-var': 'error',
|
|
45
|
+
// console.log/error *is* this CLI's actual output mechanism (job
|
|
46
|
+
// status, tables, error messages), not leftover debugging.
|
|
47
|
+
'no-console': 'off',
|
|
48
|
+
'no-restricted-syntax': [
|
|
49
|
+
'error',
|
|
50
|
+
{
|
|
51
|
+
selector: 'CallExpression[callee.object.name="console"][callee.property.name!=/^(log|warn|error|info|trace)$/]',
|
|
52
|
+
message: 'Unexpected property on console object was called'
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
curly: 'error',
|
|
56
|
+
|
|
57
|
+
// Stylistic
|
|
58
|
+
'@stylistic/indent': ['error', 2],
|
|
59
|
+
'@stylistic/key-spacing': ['error', { beforeColon: false, afterColon: true, mode: 'strict' }],
|
|
60
|
+
'@stylistic/keyword-spacing': ['error', { before: false, after: true }],
|
|
61
|
+
'@stylistic/linebreak-style': ['error', 'unix'],
|
|
62
|
+
'@stylistic/space-before-function-paren': ['error', { anonymous: 'always', named: 'never', asyncArrow: 'always' }],
|
|
63
|
+
'@stylistic/space-infix-ops': 'error',
|
|
64
|
+
'@stylistic/space-before-blocks': ['error', 'never'],
|
|
65
|
+
'@stylistic/comma-dangle': ['error', 'never'],
|
|
66
|
+
'@stylistic/max-len': ['error', 160],
|
|
67
|
+
'@stylistic/no-trailing-spaces': ['error', { skipBlankLines: false }],
|
|
68
|
+
'@stylistic/no-multiple-empty-lines': ['error', { max: 1 }],
|
|
69
|
+
'@stylistic/semi': ['error', 'always'],
|
|
70
|
+
'@stylistic/quotes': ['error', 'single'],
|
|
71
|
+
'@stylistic/one-var-declaration-per-line': ['error', 'always'],
|
|
72
|
+
'@stylistic/no-extra-semi': 'error',
|
|
73
|
+
'@stylistic/no-multi-spaces': 'error',
|
|
74
|
+
'@stylistic/no-mixed-spaces-and-tabs': 'error',
|
|
75
|
+
'@stylistic/arrow-parens': ['error', 'always'],
|
|
76
|
+
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
|
|
77
|
+
'@stylistic/block-spacing': 'error',
|
|
78
|
+
'@stylistic/brace-style': ['error', 'stroustrup'],
|
|
79
|
+
'@stylistic/comma-spacing': ['error', { before: false, after: true }],
|
|
80
|
+
'@stylistic/comma-style': ['error', 'last'],
|
|
81
|
+
'@stylistic/dot-location': ['error', 'property'],
|
|
82
|
+
'@stylistic/function-call-spacing': ['error', 'never'],
|
|
83
|
+
|
|
84
|
+
// Promises
|
|
85
|
+
'promise/always-return': ['error', { ignoreLastCallback: true }],
|
|
86
|
+
'promise/param-names': 'off',
|
|
87
|
+
'promise/catch-or-return': ['error', { allowFinally: true }],
|
|
88
|
+
'promise/no-native': 'off',
|
|
89
|
+
'promise/no-nesting': 'off',
|
|
90
|
+
'promise/no-callback-in-promise': 'off',
|
|
91
|
+
'promise/avoid-new': 'off',
|
|
92
|
+
'promise/no-new-statics': 'error',
|
|
93
|
+
'promise/no-return-in-finally': 'warn',
|
|
94
|
+
'promise/valid-params': 'warn'
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@andrian.yablonskyy/thub-agent",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "TestHub Agent CLI — the single entry point CI/CD and developers use to submit and follow test jobs",
|
|
5
|
+
"bin": {
|
|
6
|
+
"thub": "./src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "src/cli.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"lint:fix": "eslint . --fix"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@andrian.yablonskyy/thub-common": "*",
|
|
15
|
+
"commander": "^13.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
19
|
+
"eslint": "^10.11.0",
|
|
20
|
+
"eslint-plugin-promise": "^7.3.0",
|
|
21
|
+
"globals": "^17.12.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file packages/agent/src/cli.js
|
|
5
|
+
* @description thub CLI entry point: run/status/cancel/resources/jobs/config commands (README §7)
|
|
6
|
+
*
|
|
7
|
+
* @author Andrian Yablonskyy
|
|
8
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
9
|
+
*
|
|
10
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
11
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
12
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
13
|
+
* from AdSystem.PRO.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { Command } = require('commander'),
|
|
19
|
+
{ ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState } = require('@andrian.yablonskyy/thub-common'),
|
|
20
|
+
{ resolveConnection, resolveGroup, resolveUser, writeConfigFile, readConfigFile } = require('./config'),
|
|
21
|
+
{ parseDurationSec } = require('./duration'),
|
|
22
|
+
{ followJob } = require('./streaming');
|
|
23
|
+
|
|
24
|
+
const program = new Command();
|
|
25
|
+
program
|
|
26
|
+
.name('thub')
|
|
27
|
+
.description('TestHub Agent — submit test jobs and follow them, from CI or your laptop')
|
|
28
|
+
.option('--url <url>', 'Coordinator URL (overrides THUB_URL / config file)')
|
|
29
|
+
.option('--token <token>', 'Agent token (overrides THUB_TOKEN / config file)');
|
|
30
|
+
|
|
31
|
+
function client(){
|
|
32
|
+
const { url, token } = resolveConnection(program.opts());
|
|
33
|
+
return new ApiClient({ baseUrl: url, token });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(err){
|
|
37
|
+
console.error(`Error: ${err.message}`);
|
|
38
|
+
process.exit(err.status && Number.isInteger(err.status) && err.status < 100 ? err.status : EXIT_CODES.USAGE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
program
|
|
42
|
+
.command('run')
|
|
43
|
+
.description('Submit a test job and follow its log')
|
|
44
|
+
.requiredOption('--type <hw|sw>', 'Required resource type')
|
|
45
|
+
.option('--board <name>', 'Shorthand for --label board:<name>')
|
|
46
|
+
.option('--label <label>', 'Required label the resource must have (repeatable)', collectRepeatable, [])
|
|
47
|
+
.option(
|
|
48
|
+
'--group <groupId>',
|
|
49
|
+
'Restrict scheduling to resources that are members of this group (§13.1). ' +
|
|
50
|
+
'Overrides THUB_GROUP / config file; leave unset for an unconstrained run.'
|
|
51
|
+
)
|
|
52
|
+
.option(
|
|
53
|
+
'--user <name>',
|
|
54
|
+
'Free-text job owner, shown on the Client and the dashboard to tell whose job is whose ' +
|
|
55
|
+
'— purely a label, not an identity. Overrides THUB_USER / config file.'
|
|
56
|
+
)
|
|
57
|
+
.requiredOption('--image <url>', 'Firmware/build image URL (Artifactory or a Docker registry blob) fetched by the Client')
|
|
58
|
+
.option('--sha256 <hex>', 'Expected sha256 of --image; the Client verifies it before flashing/running')
|
|
59
|
+
.requiredOption('--tests <url>', 'Test package URL in Artifactory')
|
|
60
|
+
.option('--suite <name>', 'Test suite name', 'default')
|
|
61
|
+
.option('--arg <value>', 'Extra argument passed through to run-tests.sh on the Client (repeatable)', collectRepeatable, [])
|
|
62
|
+
.option('--timeout <duration>', 'e.g. 30m, 1h', '30m')
|
|
63
|
+
.option('--priority <n>', 'Priority 0-100', (v) => Number(v))
|
|
64
|
+
.option('--wait', 'Do not detach on job end; exit with the verdict code (used in CI)', false)
|
|
65
|
+
.option('--detach', 'Print the job id and exit immediately', false)
|
|
66
|
+
.option('--json', 'Machine-readable output', false)
|
|
67
|
+
.option(
|
|
68
|
+
'--meta <keyValue>',
|
|
69
|
+
'Extra metadata key=value, stored on the job and returned by `thub status --json` (repeatable). ' +
|
|
70
|
+
'Use this to carry CI job ids, git repo/branch/sha/tag, or anything else you want attached to the run.',
|
|
71
|
+
collectRepeatable,
|
|
72
|
+
[]
|
|
73
|
+
)
|
|
74
|
+
.option('--source <ci|cli>', 'Override auto-detected job source')
|
|
75
|
+
.option(
|
|
76
|
+
'--dry-run',
|
|
77
|
+
'Exercise the full pipeline (schedule, accept, state transitions, logs, artifact, result) ' +
|
|
78
|
+
'without the Client flashing/running anything for real',
|
|
79
|
+
false
|
|
80
|
+
)
|
|
81
|
+
.action(async (opts) => {
|
|
82
|
+
try {
|
|
83
|
+
const c = client(),
|
|
84
|
+
source = opts.source || (process.env.GITHUB_ACTIONS === 'true' ? 'ci' : 'cli'),
|
|
85
|
+
labels = [...(opts.board ? [`board:${opts.board}`] : []), ...opts.label],
|
|
86
|
+
meta = Object.fromEntries(opts.meta.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
|
|
87
|
+
group = resolveGroup({ group: opts.group }),
|
|
88
|
+
user = resolveUser({ user: opts.user }),
|
|
89
|
+
|
|
90
|
+
spec = {
|
|
91
|
+
target: { type: opts.type, labels, ...(group ? { group } : {}) },
|
|
92
|
+
firmware: { url: opts.image, ...(opts.sha256 ? { sha256: opts.sha256 } : {}) },
|
|
93
|
+
tests: { url: opts.tests, suite: opts.suite, args: opts.arg },
|
|
94
|
+
timeoutSec: parseDurationSec(opts.timeout),
|
|
95
|
+
priority: opts.priority ?? (source === 'ci' ? 50 : 60),
|
|
96
|
+
source,
|
|
97
|
+
...(user ? { user } : {}),
|
|
98
|
+
...(Object.keys(meta).length ? { meta } : {}),
|
|
99
|
+
...(opts.dryRun ? { dryRun: true } : {})
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
result = await c.post('/jobs', spec);
|
|
103
|
+
if (opts.json){
|
|
104
|
+
console.log(JSON.stringify(result));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
console.log(`Job ${result.jobId} queued`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (opts.detach){
|
|
111
|
+
return process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const code = await followJob(c, result.jobId, { waitMode: opts.wait });
|
|
115
|
+
process.exit(code);
|
|
116
|
+
}
|
|
117
|
+
catch (err){
|
|
118
|
+
fail(err);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
program
|
|
123
|
+
.command('status')
|
|
124
|
+
.description('Show status; follow log if running, show artifacts if done')
|
|
125
|
+
.argument('<jobId>')
|
|
126
|
+
.option('--json', 'Machine-readable output', false)
|
|
127
|
+
.action(async (jobId, opts) => {
|
|
128
|
+
try {
|
|
129
|
+
const c = client(),
|
|
130
|
+
job = await c.get(`/jobs/${jobId}`);
|
|
131
|
+
if (opts.json && !ACTIVE_JOB_STATES.has(job.state)){
|
|
132
|
+
console.log(JSON.stringify(job));
|
|
133
|
+
return process.exit(exitCodeForJobState(job.state));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(`Job ${job.id} — ${job.state}${job.resource ? ' on ' + job.resource.name : ''}`);
|
|
137
|
+
console.log(`Created: ${job.created_at}`);
|
|
138
|
+
|
|
139
|
+
if (ACTIVE_JOB_STATES.has(job.state)){
|
|
140
|
+
const code = await followJob(c, jobId, { fromSeq: 0, waitMode: false });
|
|
141
|
+
return process.exit(code);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { artifacts } = await c.get(`/jobs/${jobId}/artifacts`);
|
|
145
|
+
console.log(`Verdict: ${job.state}`);
|
|
146
|
+
for (const a of artifacts){
|
|
147
|
+
console.log(` ${a.name} ${a.url}`);
|
|
148
|
+
}
|
|
149
|
+
process.exit(exitCodeForJobState(job.state));
|
|
150
|
+
}
|
|
151
|
+
catch (err){
|
|
152
|
+
fail(err);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
program
|
|
157
|
+
.command('cancel')
|
|
158
|
+
.description('Cancel a job')
|
|
159
|
+
.argument('<jobId>')
|
|
160
|
+
.action(async (jobId) => {
|
|
161
|
+
try {
|
|
162
|
+
const job = await client().post(`/jobs/${jobId}/cancel`);
|
|
163
|
+
console.log(`Job ${job.id} -> ${job.state}`);
|
|
164
|
+
}
|
|
165
|
+
catch (err){
|
|
166
|
+
fail(err);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
program
|
|
171
|
+
.command('resources')
|
|
172
|
+
.description('List resources and their status')
|
|
173
|
+
.option('--json', 'Machine-readable output', false)
|
|
174
|
+
.action(async (opts) => {
|
|
175
|
+
try {
|
|
176
|
+
const { resources } = await client().get('/resources');
|
|
177
|
+
if (opts.json){
|
|
178
|
+
return console.log(JSON.stringify(resources));
|
|
179
|
+
}
|
|
180
|
+
printTable(resources, [
|
|
181
|
+
['NAME', (r) => r.name],
|
|
182
|
+
['TYPE', (r) => r.type],
|
|
183
|
+
['STATUS', (r) => r.status],
|
|
184
|
+
['BUSY', (r) => r.busySource || '-'],
|
|
185
|
+
['LABELS', (r) => r.labels.join(',')],
|
|
186
|
+
['LAST HEARTBEAT', (r) => r.lastHeartbeatAt || 'never']
|
|
187
|
+
]);
|
|
188
|
+
}
|
|
189
|
+
catch (err){
|
|
190
|
+
fail(err);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
program
|
|
195
|
+
.command('jobs')
|
|
196
|
+
.description('List recent jobs')
|
|
197
|
+
.option('--mine', 'Only jobs submitted by this agent token', false)
|
|
198
|
+
.option('--state <state>', 'Filter by state')
|
|
199
|
+
.option('--json', 'Machine-readable output', false)
|
|
200
|
+
.action(async (opts) => {
|
|
201
|
+
try {
|
|
202
|
+
const { jobs } = await client().get('/jobs', { query: { mine: opts.mine, state: opts.state } });
|
|
203
|
+
if (opts.json){
|
|
204
|
+
return console.log(JSON.stringify(jobs));
|
|
205
|
+
}
|
|
206
|
+
printTable(jobs, [
|
|
207
|
+
['ID', (j) => j.id],
|
|
208
|
+
['SOURCE', (j) => j.source],
|
|
209
|
+
['USER', (j) => j.spec.user || '-'],
|
|
210
|
+
['STATE', (j) => j.state],
|
|
211
|
+
['CREATED', (j) => j.created_at]
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
catch (err){
|
|
215
|
+
fail(err);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const config = program.command('config').description('Manage local Agent configuration');
|
|
220
|
+
config
|
|
221
|
+
.command('set')
|
|
222
|
+
.argument('<key>', 'url | token | group | user')
|
|
223
|
+
.argument('<value>')
|
|
224
|
+
.action((key, value) => {
|
|
225
|
+
if (!['url', 'token', 'group', 'user'].includes(key)){
|
|
226
|
+
console.error('Error: key must be "url", "token", "group", or "user"');
|
|
227
|
+
process.exit(EXIT_CODES.USAGE);
|
|
228
|
+
}
|
|
229
|
+
const current = readConfigFile();
|
|
230
|
+
writeConfigFile({ ...current, [key]: value });
|
|
231
|
+
console.log(`Saved ${key} to config`);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
function collectRepeatable(value, previous){
|
|
235
|
+
return [...previous, value];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function printTable(rows, columns){
|
|
239
|
+
if (rows.length === 0){
|
|
240
|
+
console.log('(none)');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const widths = columns.map(([header], i) => Math.max(header.length, ...rows.map((r) => String(columns[i][1](r)).length))),
|
|
244
|
+
line = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ');
|
|
245
|
+
console.log(line(columns.map(([h]) => h)));
|
|
246
|
+
for (const row of rows){
|
|
247
|
+
console.log(line(columns.map(([, f]) => f(row))));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
program.parseAsync(process.argv);
|
package/src/config.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/agent/src/config.js
|
|
3
|
+
* @description Agent config resolution: flags > env > config file > bundled default (url/token/group/user)
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs'),
|
|
17
|
+
os = require('node:os'),
|
|
18
|
+
path = require('node:path');
|
|
19
|
+
|
|
20
|
+
// §7: "Configuration is read from flags, then environment (THUB_URL,
|
|
21
|
+
// THUB_TOKEN), then ~/.config/thub/config.json."
|
|
22
|
+
const CONFIG_PATH = path.join(os.homedir(), '.config', 'thub', 'config.json'),
|
|
23
|
+
|
|
24
|
+
// Bundled with the package as a last-resort default, below the user's own
|
|
25
|
+
// config file, so `thub` has something to fall back on before `config set`
|
|
26
|
+
// has ever been run.
|
|
27
|
+
PACKAGE_DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config.json');
|
|
28
|
+
|
|
29
|
+
function readJsonFile(filePath){
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readConfigFile(){
|
|
39
|
+
return readJsonFile(CONFIG_PATH);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeConfigFile(config){
|
|
43
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
44
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveConnection(flags = {}){
|
|
48
|
+
const file = { ...readJsonFile(PACKAGE_DEFAULT_CONFIG_PATH), ...readConfigFile() },
|
|
49
|
+
url = flags.url || process.env.THUB_URL || file.url,
|
|
50
|
+
token = flags.token || process.env.THUB_TOKEN || file.token;
|
|
51
|
+
if (!url || !token){
|
|
52
|
+
const err = new Error(
|
|
53
|
+
'Missing coordinator URL or token. Set with `thub config set url <url>` / `thub config set token <token>`, ' +
|
|
54
|
+
'or THUB_URL / THUB_TOKEN, or --url / --token.'
|
|
55
|
+
);
|
|
56
|
+
err.status = 4;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
return { url, token };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Optional default for `--group` (§4.3/§7.1) — same flags > env > file
|
|
63
|
+
// precedence as url/token, but unset is fine: a job just runs unconstrained
|
|
64
|
+
// by group, same as if `groups` had never been used at all.
|
|
65
|
+
function resolveGroup(flags = {}){
|
|
66
|
+
const file = { ...readJsonFile(PACKAGE_DEFAULT_CONFIG_PATH), ...readConfigFile() };
|
|
67
|
+
return flags.group || process.env.THUB_GROUP || file.group || undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Optional default for `--user` (§4.3/§7.1) — same flags > env > file
|
|
71
|
+
// precedence as group. Purely a label (who submitted this job), not an
|
|
72
|
+
// identity: unset is fine, and nothing on the Coordinator side enforces
|
|
73
|
+
// or authenticates it.
|
|
74
|
+
function resolveUser(flags = {}){
|
|
75
|
+
const file = { ...readJsonFile(PACKAGE_DEFAULT_CONFIG_PATH), ...readConfigFile() };
|
|
76
|
+
return flags.user || process.env.THUB_USER || file.user || undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { CONFIG_PATH, readConfigFile, writeConfigFile, resolveConnection, resolveGroup, resolveUser };
|
package/src/duration.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/agent/src/duration.js
|
|
3
|
+
* @description Parses human-readable durations (e.g. 30m, 1h) into seconds
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const UNITS = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
17
|
+
|
|
18
|
+
function parseDurationSec(input){
|
|
19
|
+
if (input === undefined){
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (typeof input === 'number'){
|
|
23
|
+
return input;
|
|
24
|
+
}
|
|
25
|
+
const match = /^(\d+)([smhd])?$/.exec(String(input).trim());
|
|
26
|
+
if (!match){
|
|
27
|
+
throw new Error(`Invalid duration "${input}" — use e.g. 30m, 1h, 900s`);
|
|
28
|
+
}
|
|
29
|
+
const [, n, unit] = match;
|
|
30
|
+
return Number(n) * (UNITS[unit] || 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { parseDurationSec };
|
package/src/streaming.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/agent/src/streaming.js
|
|
3
|
+
* @description Follows a job's live log stream and resolves the CLI's exit code
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { exitCodeForJobState, EXIT_CODES } = require('@andrian.yablonskyy/thub-common');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Streams a job's logs until it reaches a terminal state, or the user
|
|
20
|
+
* hits Ctrl-C. Reconnects with exponential backoff and resumes from the
|
|
21
|
+
* last seen `seq` on network drops (§7).
|
|
22
|
+
*
|
|
23
|
+
* In --wait mode (used by CI, §11) Ctrl-C/SIGINT is treated as a cancel
|
|
24
|
+
* request; otherwise it only detaches and the job keeps running.
|
|
25
|
+
*/
|
|
26
|
+
async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print = console.log } = {}){
|
|
27
|
+
let lastEventId = fromSeq,
|
|
28
|
+
finished = false,
|
|
29
|
+
backoffMs = 500;
|
|
30
|
+
const controller = new AbortController(),
|
|
31
|
+
|
|
32
|
+
onSigint = async () => {
|
|
33
|
+
if (finished){
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
finished = true;
|
|
37
|
+
controller.abort();
|
|
38
|
+
if (waitMode){
|
|
39
|
+
print('\nReceived cancel signal — canceling job...');
|
|
40
|
+
try {
|
|
41
|
+
await client.post(`/jobs/${jobId}/cancel`);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// best effort — the process is exiting either way
|
|
45
|
+
}
|
|
46
|
+
resolveOnce(EXIT_CODES.CANCELED);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
print(`\nDetached. Job keeps running.\nthub status ${jobId}`);
|
|
50
|
+
resolveOnce(EXIT_CODES.DETACHED);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let resolveOnce;
|
|
55
|
+
const done = new Promise((resolve) => {
|
|
56
|
+
resolveOnce = (code) => {
|
|
57
|
+
finished = true;
|
|
58
|
+
resolve(code);
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
process.on('SIGINT', onSigint);
|
|
63
|
+
|
|
64
|
+
(async () => {
|
|
65
|
+
while (!finished){
|
|
66
|
+
try {
|
|
67
|
+
await client.streamEvents(`/jobs/${jobId}/logs/stream`, {
|
|
68
|
+
lastEventId,
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
onEvent: ({ event, id, data }) => {
|
|
71
|
+
if (id){
|
|
72
|
+
lastEventId = id;
|
|
73
|
+
}
|
|
74
|
+
if (event === 'log'){
|
|
75
|
+
print(`[${data.stream}] ${data.line}`);
|
|
76
|
+
}
|
|
77
|
+
else if (event === 'state'){
|
|
78
|
+
print(`-- ${data.state}${data.resource ? ' on ' + data.resource : ''} --`);
|
|
79
|
+
}
|
|
80
|
+
else if (event === 'end'){
|
|
81
|
+
print(`\nJob finished: ${data.state}`);
|
|
82
|
+
if (data.artifactsUrl){
|
|
83
|
+
print(`Artifacts: ${data.artifactsUrl}`);
|
|
84
|
+
}
|
|
85
|
+
resolveOnce(exitCodeForJobState(data.state));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (!finished){
|
|
90
|
+
// Server closed the stream without an `end` event (rare) — retry.
|
|
91
|
+
await sleep(backoffMs);
|
|
92
|
+
backoffMs = Math.min(backoffMs * 2, 10_000);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
if (controller.signal.aborted){
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
await sleep(backoffMs);
|
|
100
|
+
backoffMs = Math.min(backoffMs * 2, 10_000);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await done;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
process.off('SIGINT', onSigint);
|
|
110
|
+
controller.abort();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sleep(ms){
|
|
115
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { followJob };
|