Skip to content

Commit 878c418

Browse files
committed
Harden standalone runtime kit
1 parent f9228d2 commit 878c418

17 files changed

Lines changed: 369 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ jobs:
3737

3838
- name: Test
3939
run: npm test
40+
41+
- name: Verify package exports
42+
run: npm run test:package

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
All notable changes to `aigc-provider-runtime-kit` are recorded here.
44

5+
## Unreleased
6+
7+
### Fixed
8+
9+
- Correct video-model reference image capability inference.
10+
- Reject unknown RunningHub task states instead of treating them as successful completion.
11+
12+
### Added
13+
14+
- Typed `RunningHubError` failures, bounded request/task timeouts, and task cancellation.
15+
- Configurable RunningHub key-pool leases.
16+
- Package export verification and release-safe npm package contents.
17+
- Tests for multipart data URLs, RunningHub execution failures/cancellation, and key-pool leases.
18+
519
## 0.1.0 - 2026-07-08
620

721
### Added

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Most AIGC applications eventually need the same provider infrastructure:
2929
- **RunningHub catalog helpers**: normalize RunningHub App/Workflow records into reusable host-app entries.
3030
- **Execution descriptors**: generate submit/poll/output handling metadata for RunningHub tasks.
3131
- **RunningHub client**: submit tasks, poll results, normalize failures, and extract image/video/audio URLs.
32+
- **Bounded execution**: task and request timeouts, cancellation signals, typed errors, and unknown-status protection.
3233
- **Key-pool concurrency**: acquire and release RunningHub keys against a Redis-like runtime interface.
3334
- **No framework lock-in**: works with Node.js services, workers, CLI tools, or any framework that can import ESM.
3435
- **Governed project harness**: includes docs, CI, tests, and verification scripts to keep the package maintainable.
@@ -130,6 +131,33 @@ const result = await client.runTask({
130131
console.log(result.videoUrls);
131132
```
132133

134+
Cancel a task from your host application and handle structured failures:
135+
136+
```ts
137+
import {
138+
createRunningHubClient,
139+
isRunningHubError
140+
} from "aigc-provider-runtime-kit/runninghub";
141+
142+
const controller = new AbortController();
143+
144+
try {
145+
await client.runTask({
146+
targetType: "workflow",
147+
runTargetId: "workflow-id",
148+
workflowId: "workflow-id",
149+
nodeInfoList: [],
150+
signal: controller.signal
151+
});
152+
} catch (error) {
153+
if (isRunningHubError(error)) {
154+
console.error(error.code, error.stage, error.retryable);
155+
}
156+
}
157+
```
158+
159+
The client defaults to a 30-minute task timeout and a 2-minute timeout per HTTP request. Override them with `taskTimeoutMs` and `requestTimeoutMs` when creating the client, or use `timeoutMs` for one task.
160+
133161
Use key-pool helpers with a Redis-like runtime:
134162

135163
```ts
@@ -141,6 +169,7 @@ import {
141169
const acquired = await acquireRunningHubKey({
142170
providerId: "runninghub",
143171
defaultConcurrency: 2,
172+
leaseSeconds: 60 * 60,
144173
runtime: redisLikeRuntime,
145174
keys: [
146175
{
@@ -169,6 +198,8 @@ try {
169198
}
170199
```
171200

201+
Choose a lease long enough for the longest expected task. Each successful acquisition refreshes the lease, and the default is one hour.
202+
172203
## What You Can Build With It
173204

174205
- A multi-provider AIGC backend.
@@ -232,6 +263,7 @@ npm run harness:verify:project
232263
npm run type-check
233264
npm run build
234265
npm test
266+
npm run test:package
235267
```
236268

237269
Use the full release gate before publishing or tagging:

docs/api-reference.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ Normalizes a source catalog record into a complete RunningHub catalog item.
5757

5858
Creates a minimal RunningHub submit/poll client. Host applications should wrap this client with their own permission, quota, credential, and audit layers.
5959

60+
Client options include `requestTimeoutMs` for individual HTTP requests and `taskTimeoutMs` for the complete polling lifecycle. Both have bounded defaults. `runTask` also accepts a per-task `timeoutMs` and an `AbortSignal` through `signal`.
61+
62+
### `RunningHubError` and `isRunningHubError(value)`
63+
64+
RunningHub failures use a typed error with `code`, `stage`, optional HTTP `status`, and a `retryable` hint. Codes distinguish invalid configuration/input, cancellation, request or response failures, upstream rejection, task failure/timeout, unknown status, and missing output.
65+
6066
### `extractRunningHubOutputUrls(value)`
6167

6268
Extracts image, video, and audio URLs from nested RunningHub result payloads.
@@ -65,6 +71,8 @@ Extracts image, video, and audio URLs from nested RunningHub result payloads.
6571

6672
Provide key-pool concurrency helpers against a Redis-like runtime interface.
6773

74+
`acquireRunningHubKey` accepts an optional `leaseSeconds` value between 30 seconds and 24 hours. Successful acquisitions refresh the counter lease.
75+
6876
### `orderRunningHubKeys(keys, preferredKeyId?)`
6977

7078
Orders enabled keys by preferred key, default key, then other enabled keys.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# 2026-07-10 Runtime Hardening
2+
3+
## Intent
4+
5+
Harden the standalone SDK so clean checkouts can build, test, package, and safely execute bounded RunningHub tasks.
6+
7+
## Changed
8+
9+
| Area | Files | Purpose |
10+
| --- | --- | --- |
11+
| Model metadata | `packages/core/src/model-schema.ts` | Correct video reference-image capability inference |
12+
| RunningHub runtime | `packages/runninghub/src/*` | Add typed errors, timeouts, cancellation, status validation, and configurable key leases |
13+
| Package release | `package.json`, `scripts/verify-package.mjs`, `.github/workflows/ci.yml` | Build before packing, constrain Node, limit package contents, and verify exports |
14+
| Tests | `tests/*.test.mjs` | Cover multipart, client, cancellation, status handling, and key-pool behavior |
15+
| Documentation | `README.md`, `docs/*`, `CHANGELOG.md` | Document public behavior and release guarantees |
16+
17+
## Verification
18+
19+
| Check | Result |
20+
| --- | --- |
21+
| `npm run harness:verify:project` | Passed |
22+
| `npm run type-check` | Passed |
23+
| `npm test` | Passed (15 tests) |
24+
| `npm run test:package` | Passed |
25+
| `npm pack --dry-run` | Passed; examples excluded |
26+
27+
## Decisions
28+
29+
- Keep the project a framework-neutral SDK rather than adding a hosted API, database, queue, or admin UI.
30+
- Preserve existing public functions while adding optional safeguards and typed error exports.
31+
- Use Node.js 22 as the explicit supported runtime baseline.
32+
33+
## Risks
34+
35+
- Upstream RunningHub response shapes may evolve and require additional fixtures.
36+
- Retry/backoff remains a host policy and is not automated in this change.
37+
38+
## Next
39+
40+
Add provider registry validation and additional provider adapters in later releases.

docs/roadmap.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@ Completed:
1111
- Multipart request body helper.
1212
- RunningHub catalog and execution descriptor helpers.
1313
- RunningHub submit/poll client.
14+
- Bounded RunningHub task/request timeouts, cancellation, and typed errors.
1415
- RunningHub key-pool concurrency helper.
16+
- Package export verification and release-safe package contents.
1517
- Harness, CI, docs, and unit tests.
1618

1719
## 0.2.x Candidate Work
1820

1921
- Add provider adapter examples for OpenAI-compatible image/video APIs.
2022
- Add fixtures for common RunningHub App and Workflow shapes.
21-
- Add error normalization helpers shared by provider clients.
23+
- Add retry/backoff policy helpers shared by provider clients.
2224
- Add optional host-app adapter examples for Express/Fastify without making them core dependencies.
2325
- Add package export compatibility tests.
2426

docs/systems/runninghub/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ The RunningHub system provides reusable contracts and helpers for RunningHub AI
1919
4. Host worker uses `createRunningHubClient` to submit and poll.
2020
5. Optional key-pool helpers manage multi-key concurrency.
2121

22+
The client applies bounded request and task timeouts, accepts an `AbortSignal`, and reports typed `RunningHubError` failures. The key pool uses a configurable concurrency lease so stale counters can recover.
23+
2224
## Interfaces
2325

2426
| Interface | Direction | Contract |
@@ -33,6 +35,8 @@ The RunningHub system provides reusable contracts and helpers for RunningHub AI
3335
- App/workflow IDs are mixed.
3436
- Fields do not match upstream RunningHub node info.
3537
- Poll returns success with no usable output URL.
38+
- Poll returns an unknown status or exceeds its timeout.
39+
- The host aborts an in-flight task.
3640
- All keys are busy or no enabled keys exist.
3741

3842
## Verification

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
"main": "./dist/packages/runtime/src/index.js",
99
"types": "./dist/packages/runtime/src/index.d.ts",
1010
"sideEffects": false,
11+
"engines": {
12+
"node": ">=22"
13+
},
1114
"files": [
12-
"dist",
15+
"dist/packages",
1316
"README.md",
1417
"LICENSE",
1518
"CHANGELOG.md"
@@ -32,12 +35,14 @@
3235
],
3336
"scripts": {
3437
"build": "tsc -p tsconfig.json",
38+
"prepack": "npm run build",
3539
"type-check": "tsc -p tsconfig.json --noEmit",
3640
"test": "npm run build && node --test tests/*.test.mjs",
41+
"test:package": "npm run build && node scripts/verify-package.mjs",
3742
"verify:harness": "node scripts/verify-harness.mjs",
3843
"harness:verify:project": "npm run verify:harness",
3944
"harness:verify:workspace": "npm run type-check && npm test",
40-
"harness:verify:release": "npm run verify:harness && npm run type-check && npm test"
45+
"harness:verify:release": "npm run verify:harness && npm run type-check && npm test && npm run test:package"
4146
},
4247
"devDependencies": {
4348
"typescript": "^5.5.0"

packages/core/src/model-schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ export function parseInputCapabilities(value: unknown, capability: ModelCapabili
6262
const maxRef = Number(schema.referenceImages?.max);
6363
return {
6464
prompt: source.prompt !== false,
65-
imageReference: source.imageReference === undefined ? capability === "image" && Boolean(schema.referenceImages) : Boolean(source.imageReference),
66-
multiImage: source.multiImage === undefined ? capability === "image" && Number.isFinite(maxRef) && maxRef > 1 : Boolean(source.multiImage),
65+
imageReference: source.imageReference === undefined ? Boolean(schema.referenceImages) : Boolean(source.imageReference),
66+
multiImage: source.multiImage === undefined ? Number.isFinite(maxRef) && maxRef > 1 : Boolean(source.multiImage),
6767
firstFrame: Boolean(source.firstFrame),
6868
lastFrame: Boolean(source.lastFrame),
6969
mask: Boolean(source.mask),

packages/runninghub/src/client.ts

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import { RunningHubError } from "./error.js";
2+
13
export interface RunningHubClientOptions {
24
apiKey: string;
35
baseUrl: string;
46
fetcher?: typeof fetch;
57
wait?: (ms: number) => Promise<void>;
8+
requestTimeoutMs?: number;
9+
taskTimeoutMs?: number;
610
}
711

812
export interface RunningHubNodeInfoItem {
@@ -20,6 +24,7 @@ export interface RunningHubRunInput {
2024
instanceType?: string;
2125
pollIntervalMs?: number;
2226
timeoutMs?: number;
27+
signal?: AbortSignal;
2328
onHeartbeat?: (patch: Record<string, unknown>) => void | Promise<void>;
2429
}
2530

@@ -35,15 +40,19 @@ export interface RunningHubRunResult {
3540
}
3641

3742
export function createRunningHubClient(options: RunningHubClientOptions) {
43+
const apiKey = safeText(options.apiKey);
44+
const baseUrl = safeText(options.baseUrl).replace(/\/+$/, "");
45+
if (!apiKey) throw new RunningHubError("INVALID_CONFIGURATION", "RunningHub apiKey is required", { stage: "configuration" });
46+
if (!/^https?:\/\//i.test(baseUrl)) throw new RunningHubError("INVALID_CONFIGURATION", "RunningHub baseUrl must be an HTTP(S) URL", { stage: "configuration" });
3847
const fetcher = options.fetcher ?? fetch;
3948
const wait = options.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, ms))));
4049

4150
async function runTask(input: RunningHubRunInput): Promise<RunningHubRunResult> {
4251
const targetType = input.targetType;
4352
const runTargetId = safeText(input.runTargetId);
44-
if (!runTargetId) throw new Error(targetType === "app" ? "RunningHub app runTargetId is required" : "RunningHub workflow runTargetId is required");
53+
if (!runTargetId) throw new RunningHubError("INVALID_INPUT", targetType === "app" ? "RunningHub app runTargetId is required" : "RunningHub workflow runTargetId is required", { stage: "submit" });
54+
throwIfAborted(input.signal, "submit");
4555

46-
const baseUrl = options.baseUrl.replace(/\/+$/, "");
4756
const appId = safeText(input.appId || (targetType === "app" ? runTargetId : ""));
4857
const workflowId = safeText(input.workflowId || (targetType === "workflow" ? runTargetId : ""));
4958
const submitUrl = targetType === "app"
@@ -58,18 +67,18 @@ export function createRunningHubClient(options: RunningHubClientOptions) {
5867
usePersonalQueue: "false"
5968
}
6069
: {
61-
apiKey: options.apiKey,
70+
apiKey,
6271
workflowId: workflowId || runTargetId,
6372
nodeInfoList: input.nodeInfoList,
6473
...(safeText(input.instanceType) ? { instanceType: safeText(input.instanceType) } : {})
6574
};
6675

67-
const submitRaw = await postJson(fetcher, submitUrl, options.apiKey, submitBody, "submit");
76+
const submitRaw = await postJson(fetcher, submitUrl, apiKey, submitBody, "submit", options.requestTimeoutMs, input.signal);
6877
const submitData = plainObject(targetType === "app" ? plainObject(submitRaw.data) : submitRaw.data);
6978
const upstreamTaskId = safeText(submitData.taskId || submitData.id || submitRaw.taskId || submitRaw.id);
7079
const upstreamCode = Number(submitRaw.code);
7180
if ((Number.isFinite(upstreamCode) && upstreamCode !== 0) || !upstreamTaskId) {
72-
throw new Error(safeText(submitData.msg || submitRaw.message || submitRaw.msg, "RunningHub did not return a valid task id"));
81+
throw new RunningHubError("UPSTREAM_REJECTED", safeText(submitData.msg || submitRaw.message || submitRaw.msg, "RunningHub did not return a valid task id"), { stage: "submit" });
7382
}
7483

7584
await input.onHeartbeat?.({
@@ -81,13 +90,19 @@ export function createRunningHubClient(options: RunningHubClientOptions) {
8190
});
8291

8392
const startedAt = Date.now();
93+
const timeoutMs = normalizeTimeout(input.timeoutMs ?? options.taskTimeoutMs, 30 * 60 * 1000);
8494
const interval = Math.max(1000, Number(input.pollIntervalMs || 5000));
8595
for (;;) {
86-
if (input.timeoutMs && Date.now() - startedAt > input.timeoutMs) {
87-
throw new Error(`RunningHub poll timeout: ${upstreamTaskId}`);
88-
}
96+
throwIfAborted(input.signal, "poll");
97+
if (Date.now() - startedAt >= timeoutMs) throw taskTimeout(upstreamTaskId);
8998
await wait(interval);
90-
const pollRaw = await postJson(fetcher, queryUrl, options.apiKey, { taskId: upstreamTaskId }, "poll");
99+
throwIfAborted(input.signal, "poll");
100+
if (Date.now() - startedAt >= timeoutMs) throw taskTimeout(upstreamTaskId);
101+
const pollRaw = await postJson(fetcher, queryUrl, apiKey, { taskId: upstreamTaskId }, "poll", options.requestTimeoutMs, input.signal);
102+
const pollCode = Number(pollRaw.code);
103+
if (Number.isFinite(pollCode) && pollCode !== 0) {
104+
throw new RunningHubError("UPSTREAM_REJECTED", safeText(pollRaw.msg || pollRaw.message, `RunningHub poll rejected with code ${pollCode}`), { stage: "poll" });
105+
}
91106
const status = safeText(pollRaw.status || plainObject(pollRaw.data).status).toUpperCase();
92107
const message = safeText(pollRaw.errorMessage || pollRaw.msg || pollRaw.message || plainObject(pollRaw.data).message);
93108
await input.onHeartbeat?.({
@@ -101,12 +116,17 @@ export function createRunningHubClient(options: RunningHubClientOptions) {
101116

102117
if (["RUNNING", "PENDING", "QUEUED", "WAITING"].includes(status)) continue;
103118
if (["FAILED", "ERROR", "CANCELLED", "CANCELED"].includes(status)) {
104-
throw new Error(message || `RunningHub task failed: ${upstreamTaskId}`);
119+
throw new RunningHubError("TASK_FAILED", message || `RunningHub task failed: ${upstreamTaskId}`, { stage: "poll" });
105120
}
106121

107122
const urls = extractRunningHubOutputUrls(targetType === "app" ? pollRaw.results || pollRaw.data || pollRaw : pollRaw.data || pollRaw);
108-
if (urls.imageUrls.length === 0 && urls.videoUrls.length === 0 && urls.audioUrls.length === 0) {
109-
throw new Error(message || "RunningHub task completed without usable outputs");
123+
const completed = ["SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED"].includes(status);
124+
const hasOutput = urls.imageUrls.length > 0 || urls.videoUrls.length > 0 || urls.audioUrls.length > 0;
125+
if (!completed && !hasOutput) {
126+
throw new RunningHubError("UNKNOWN_TASK_STATUS", `RunningHub returned unknown task status: ${status || "<empty>"}`, { stage: "poll" });
127+
}
128+
if (!hasOutput) {
129+
throw new RunningHubError("MISSING_OUTPUT", message || "RunningHub task completed without usable outputs", { stage: "result" });
110130
}
111131
return {
112132
adapter: "runninghub",
@@ -122,22 +142,30 @@ export function createRunningHubClient(options: RunningHubClientOptions) {
122142
return { runTask };
123143
}
124144

125-
async function postJson(fetcher: typeof fetch, url: string, apiKey: string, body: Record<string, unknown>, stage: string) {
126-
const response = await fetcher(url, {
145+
async function postJson(fetcher: typeof fetch, url: string, apiKey: string, body: Record<string, unknown>, stage: "submit" | "poll", timeoutMsValue?: number, signal?: AbortSignal) {
146+
const timeoutSignal = AbortSignal.timeout(normalizeTimeout(timeoutMsValue, 120000));
147+
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
148+
let response: Response;
149+
try {
150+
response = await fetcher(url, {
127151
method: "POST",
128152
headers: {
129153
"content-type": "application/json",
130154
authorization: `Bearer ${apiKey}`
131155
},
132156
body: JSON.stringify(body),
133-
signal: AbortSignal.timeout(120000)
134-
});
157+
signal: requestSignal
158+
});
159+
} catch (cause) {
160+
if (signal?.aborted) throw new RunningHubError("REQUEST_ABORTED", `RunningHub ${stage} aborted`, { stage, cause });
161+
throw new RunningHubError("REQUEST_FAILED", `RunningHub ${stage} request failed`, { stage, retryable: true, cause });
162+
}
135163
const text = await response.text();
136-
if (!response.ok) throw new Error(`RunningHub ${stage} failed ${response.status}: ${text.slice(0, 500)}`);
164+
if (!response.ok) throw new RunningHubError("REQUEST_FAILED", `RunningHub ${stage} failed ${response.status}: ${text.slice(0, 500)}`, { stage, status: response.status, retryable: response.status === 429 || response.status >= 500 });
137165
try {
138166
return text ? JSON.parse(text) as Record<string, unknown> : {};
139167
} catch {
140-
throw new Error(`RunningHub ${stage} returned invalid JSON`);
168+
throw new RunningHubError("INVALID_RESPONSE", `RunningHub ${stage} returned invalid JSON`, { stage });
141169
}
142170
}
143171

@@ -186,3 +214,16 @@ function safeText(value: unknown, fallback = "") {
186214
if (typeof value === "number" || typeof value === "boolean") return String(value);
187215
return fallback;
188216
}
217+
218+
function normalizeTimeout(value: number | undefined, fallback: number) {
219+
if (!Number.isFinite(value)) return fallback;
220+
return Math.max(1, Math.round(value as number));
221+
}
222+
223+
function throwIfAborted(signal: AbortSignal | undefined, stage: "submit" | "poll") {
224+
if (signal?.aborted) throw new RunningHubError("REQUEST_ABORTED", `RunningHub ${stage} aborted`, { stage, cause: signal.reason });
225+
}
226+
227+
function taskTimeout(upstreamTaskId: string) {
228+
return new RunningHubError("TASK_TIMEOUT", `RunningHub poll timeout: ${upstreamTaskId}`, { stage: "poll", retryable: true });
229+
}

0 commit comments

Comments
 (0)