mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:20:44 +00:00
fix(gateway): harden artifact RPCs
Add Gateway artifact RPCs and SDK helpers for list/get/download, with transcript provenance checks, safer download source handling, task/run/session coverage, generated protocol models, docs, and the refreshed generated config schema baseline. Closes #74706. Refs #74898, #74769, #74804, #74786.
This commit is contained in:
@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Security/tools: configured tool sections (`tools.exec`, `tools.fs`) no longer implicitly widen restrictive profiles (`messaging`, `minimal`). Users who need those tools under a restricted profile must add explicit `alsoAllow` entries; a startup warning identifies affected configs. Fixes #47487. Thanks @amknight.
|
||||
- Gateway/SDK: add SDK-facing artifact list/get/download RPCs and App SDK helpers with transcript provenance and download-source guardrails. Refs #74706. Thanks @tmimmanuel.
|
||||
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
|
||||
- Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc.
|
||||
- Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc.
|
||||
|
||||
@@ -3208,6 +3208,188 @@ public struct AgentsFilesSetResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactSummary: Codable, Sendable {
|
||||
public let id: String
|
||||
public let type: String
|
||||
public let title: String
|
||||
public let mimetype: String?
|
||||
public let sizebytes: Int?
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
public let messageseq: Int?
|
||||
public let source: String?
|
||||
public let download: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
type: String,
|
||||
title: String,
|
||||
mimetype: String?,
|
||||
sizebytes: Int?,
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?,
|
||||
messageseq: Int?,
|
||||
source: String?,
|
||||
download: [String: AnyCodable])
|
||||
{
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.mimetype = mimetype
|
||||
self.sizebytes = sizebytes
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
self.messageseq = messageseq
|
||||
self.source = source
|
||||
self.download = download
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case title
|
||||
case mimetype = "mimeType"
|
||||
case sizebytes = "sizeBytes"
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
case messageseq = "messageSeq"
|
||||
case source
|
||||
case download
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsListParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsListResult: Codable, Sendable {
|
||||
public let artifacts: [ArtifactSummary]
|
||||
|
||||
public init(
|
||||
artifacts: [ArtifactSummary])
|
||||
{
|
||||
self.artifacts = artifacts
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case artifacts
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsGetParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
public let artifactid: String
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?,
|
||||
artifactid: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
self.artifactid = artifactid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
case artifactid = "artifactId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsGetResult: Codable, Sendable {
|
||||
public let artifact: ArtifactSummary
|
||||
|
||||
public init(
|
||||
artifact: ArtifactSummary)
|
||||
{
|
||||
self.artifact = artifact
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case artifact
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsDownloadParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
public let artifactid: String
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?,
|
||||
artifactid: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
self.artifactid = artifactid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
case artifactid = "artifactId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsDownloadResult: Codable, Sendable {
|
||||
public let artifact: ArtifactSummary
|
||||
public let encoding: String?
|
||||
public let data: String?
|
||||
public let url: String?
|
||||
|
||||
public init(
|
||||
artifact: ArtifactSummary,
|
||||
encoding: String?,
|
||||
data: String?,
|
||||
url: String?)
|
||||
{
|
||||
self.artifact = artifact
|
||||
self.encoding = encoding
|
||||
self.data = data
|
||||
self.url = url
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case artifact
|
||||
case encoding
|
||||
case data
|
||||
case url
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsListParams: Codable, Sendable {}
|
||||
|
||||
public struct AgentsListResult: Codable, Sendable {
|
||||
|
||||
@@ -3208,6 +3208,188 @@ public struct AgentsFilesSetResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactSummary: Codable, Sendable {
|
||||
public let id: String
|
||||
public let type: String
|
||||
public let title: String
|
||||
public let mimetype: String?
|
||||
public let sizebytes: Int?
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
public let messageseq: Int?
|
||||
public let source: String?
|
||||
public let download: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
type: String,
|
||||
title: String,
|
||||
mimetype: String?,
|
||||
sizebytes: Int?,
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?,
|
||||
messageseq: Int?,
|
||||
source: String?,
|
||||
download: [String: AnyCodable])
|
||||
{
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.mimetype = mimetype
|
||||
self.sizebytes = sizebytes
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
self.messageseq = messageseq
|
||||
self.source = source
|
||||
self.download = download
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case title
|
||||
case mimetype = "mimeType"
|
||||
case sizebytes = "sizeBytes"
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
case messageseq = "messageSeq"
|
||||
case source
|
||||
case download
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsListParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsListResult: Codable, Sendable {
|
||||
public let artifacts: [ArtifactSummary]
|
||||
|
||||
public init(
|
||||
artifacts: [ArtifactSummary])
|
||||
{
|
||||
self.artifacts = artifacts
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case artifacts
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsGetParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
public let artifactid: String
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?,
|
||||
artifactid: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
self.artifactid = artifactid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
case artifactid = "artifactId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsGetResult: Codable, Sendable {
|
||||
public let artifact: ArtifactSummary
|
||||
|
||||
public init(
|
||||
artifact: ArtifactSummary)
|
||||
{
|
||||
self.artifact = artifact
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case artifact
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsDownloadParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let runid: String?
|
||||
public let taskid: String?
|
||||
public let artifactid: String
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
runid: String?,
|
||||
taskid: String?,
|
||||
artifactid: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
self.taskid = taskid
|
||||
self.artifactid = artifactid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case runid = "runId"
|
||||
case taskid = "taskId"
|
||||
case artifactid = "artifactId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArtifactsDownloadResult: Codable, Sendable {
|
||||
public let artifact: ArtifactSummary
|
||||
public let encoding: String?
|
||||
public let data: String?
|
||||
public let url: String?
|
||||
|
||||
public init(
|
||||
artifact: ArtifactSummary,
|
||||
encoding: String?,
|
||||
data: String?,
|
||||
url: String?)
|
||||
{
|
||||
self.artifact = artifact
|
||||
self.encoding = encoding
|
||||
self.data = data
|
||||
self.url = url
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case artifact
|
||||
case encoding
|
||||
case data
|
||||
case url
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsListParams: Codable, Sendable {}
|
||||
|
||||
public struct AgentsListResult: Codable, Sendable {
|
||||
|
||||
@@ -39,6 +39,7 @@ resources.
|
||||
| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. |
|
||||
| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. |
|
||||
| `oc.tools` | Partial | Lists tool catalog and effective tools; direct tool invocation is not wired. |
|
||||
| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. |
|
||||
| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. |
|
||||
| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. |
|
||||
| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. |
|
||||
@@ -47,8 +48,10 @@ The SDK also exports the core types used by those surfaces:
|
||||
`AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`,
|
||||
`OpenClawEventType`, `GatewayEvent`, `OpenClawTransport`,
|
||||
`GatewayRequestOptions`, `SessionCreateParams`, `SessionSendParams`,
|
||||
`RuntimeSelection`, `EnvironmentSelection`, `WorkspaceSelection`,
|
||||
`ApprovalMode`, and related result types.
|
||||
`ArtifactSummary`, `ArtifactQuery`, `ArtifactsListResult`,
|
||||
`ArtifactsGetResult`, `ArtifactsDownloadResult`, `RuntimeSelection`,
|
||||
`EnvironmentSelection`, `WorkspaceSelection`, `ApprovalMode`, and related
|
||||
result types.
|
||||
|
||||
## Connect To A Gateway
|
||||
|
||||
@@ -204,7 +207,7 @@ for await (const event of run.events()) {
|
||||
For app-wide streams, use `oc.events()`. For raw Gateway frames, use
|
||||
`oc.rawEvents()`.
|
||||
|
||||
## Models, Tools, And Approvals
|
||||
## Models, Tools, Artifacts, And Approvals
|
||||
|
||||
Model helpers map to current Gateway methods:
|
||||
|
||||
@@ -220,6 +223,21 @@ await oc.tools.list();
|
||||
await oc.tools.effective({ sessionKey: "main" });
|
||||
```
|
||||
|
||||
Artifact helpers expose the Gateway artifact projection for session, run, or
|
||||
task context. Each call requires one explicit `sessionKey`, `runId`, or
|
||||
`taskId` scope:
|
||||
|
||||
```typescript
|
||||
const { artifacts } = await oc.artifacts.list({ sessionKey: "main" });
|
||||
const first = artifacts[0];
|
||||
|
||||
if (first) {
|
||||
const { artifact } = await oc.artifacts.get(first.id, { sessionKey: "main" });
|
||||
const download = await oc.artifacts.download(artifact.id, { sessionKey: "main" });
|
||||
console.log(download.encoding, download.url);
|
||||
}
|
||||
```
|
||||
|
||||
Approval helpers use the exec approval RPCs:
|
||||
|
||||
```typescript
|
||||
@@ -240,10 +258,6 @@ await oc.tasks.cancel("task-id");
|
||||
|
||||
await oc.tools.invoke("tool-name", {});
|
||||
|
||||
await oc.artifacts.list();
|
||||
await oc.artifacts.get("artifact-id");
|
||||
await oc.artifacts.download("artifact-id");
|
||||
|
||||
await oc.environments.list();
|
||||
await oc.environments.create({});
|
||||
await oc.environments.status("environment-id");
|
||||
|
||||
@@ -388,6 +388,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
- `agents.list` returns configured agent entries, including effective model and runtime metadata.
|
||||
- `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring.
|
||||
- `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent.
|
||||
- `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side.
|
||||
- `agent.identity.get` returns the effective assistant identity for an agent or session.
|
||||
- `agent.wait` waits for a run to finish and returns the terminal snapshot when available.
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { normalizeGatewayEvent } from "./normalize.js";
|
||||
import { GatewayClientTransport, isConnectableTransport } from "./transport.js";
|
||||
import type {
|
||||
AgentRunParams,
|
||||
ArtifactQuery,
|
||||
ArtifactsDownloadResult,
|
||||
ArtifactsGetResult,
|
||||
ArtifactsListResult,
|
||||
GatewayEvent,
|
||||
GatewayRequestOptions,
|
||||
OpenClawEvent,
|
||||
@@ -185,6 +189,20 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function hasArtifactQueryScope(params: unknown): params is ArtifactQuery {
|
||||
const record = asRecord(params);
|
||||
return [record.sessionKey, record.runId, record.taskId].some(
|
||||
(value) => typeof value === "string" && value.trim().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function requireArtifactQueryScope(api: string, params: unknown): ArtifactQuery {
|
||||
if (!hasArtifactQueryScope(params)) {
|
||||
throw new Error(`${api} requires one of sessionKey, runId, or taskId`);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function readChatProjection(event: OpenClawEvent): ChatProjection | undefined {
|
||||
const raw = event.raw;
|
||||
if (event.type !== "raw" || raw?.event !== "chat") {
|
||||
@@ -758,19 +776,22 @@ export class ArtifactsNamespace extends RpcNamespace {
|
||||
super(client, "artifacts");
|
||||
}
|
||||
|
||||
async list(params?: unknown): Promise<unknown> {
|
||||
void params;
|
||||
return unsupportedGatewayApi("oc.artifacts.list");
|
||||
async list(params: ArtifactQuery): Promise<ArtifactsListResult> {
|
||||
return await this.call("list", requireArtifactQueryScope("oc.artifacts.list", params));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<unknown> {
|
||||
void id;
|
||||
return unsupportedGatewayApi("oc.artifacts.get");
|
||||
async get(id: string, params: ArtifactQuery): Promise<ArtifactsGetResult> {
|
||||
return await this.call("get", {
|
||||
...requireArtifactQueryScope("oc.artifacts.get", params),
|
||||
artifactId: id,
|
||||
});
|
||||
}
|
||||
|
||||
async download(id: string): Promise<unknown> {
|
||||
void id;
|
||||
return unsupportedGatewayApi("oc.artifacts.download");
|
||||
async download(id: string, params: ArtifactQuery): Promise<ArtifactsDownloadResult> {
|
||||
return await this.call("download", {
|
||||
...requireArtifactQueryScope("oc.artifacts.download", params),
|
||||
artifactId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,65 @@ describe("OpenClaw SDK", () => {
|
||||
).rejects.toThrow("timeoutMs must be a finite non-negative number");
|
||||
});
|
||||
|
||||
it("calls artifact Gateway RPCs", async () => {
|
||||
const transport = new FakeTransport({
|
||||
"artifacts.list": { artifacts: [{ id: "artifact_123", type: "image", title: "demo.png" }] },
|
||||
"artifacts.get": { artifact: { id: "artifact_123", type: "image", title: "demo.png" } },
|
||||
"artifacts.download": {
|
||||
artifact: { id: "artifact_123", type: "image", title: "demo.png" },
|
||||
encoding: "base64",
|
||||
data: "aGVsbG8=",
|
||||
},
|
||||
});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
await expect(oc.artifacts.list({ sessionKey: "agent:main:main" })).resolves.toMatchObject({
|
||||
artifacts: [{ id: "artifact_123" }],
|
||||
});
|
||||
await expect(
|
||||
oc.artifacts.get("artifact_123", { sessionKey: "agent:main:main" }),
|
||||
).resolves.toMatchObject({
|
||||
artifact: { id: "artifact_123" },
|
||||
});
|
||||
await expect(
|
||||
oc.artifacts.download("artifact_123", { sessionKey: "agent:main:main" }),
|
||||
).resolves.toMatchObject({
|
||||
encoding: "base64",
|
||||
data: "aGVsbG8=",
|
||||
});
|
||||
|
||||
expect(transport.calls).toMatchObject([
|
||||
{
|
||||
method: "artifacts.list",
|
||||
params: { sessionKey: "agent:main:main" },
|
||||
},
|
||||
{
|
||||
method: "artifacts.get",
|
||||
params: { artifactId: "artifact_123", sessionKey: "agent:main:main" },
|
||||
},
|
||||
{
|
||||
method: "artifacts.download",
|
||||
params: { artifactId: "artifact_123", sessionKey: "agent:main:main" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires artifact query scope before calling Gateway", async () => {
|
||||
const transport = new FakeTransport({});
|
||||
const oc = new OpenClaw({ transport });
|
||||
|
||||
await expect(oc.artifacts.list(undefined as never)).rejects.toThrow(
|
||||
"oc.artifacts.list requires one of sessionKey, runId, or taskId",
|
||||
);
|
||||
await expect(oc.artifacts.get("artifact_123", undefined as never)).rejects.toThrow(
|
||||
"oc.artifacts.get requires one of sessionKey, runId, or taskId",
|
||||
);
|
||||
await expect(oc.artifacts.download("artifact_123", undefined as never)).rejects.toThrow(
|
||||
"oc.artifacts.download requires one of sessionKey, runId, or taskId",
|
||||
);
|
||||
expect(transport.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws explicit unsupported errors for SDK namespaces without Gateway RPCs", async () => {
|
||||
const transport = new FakeTransport({});
|
||||
const oc = new OpenClaw({ transport });
|
||||
@@ -279,15 +338,6 @@ describe("OpenClaw SDK", () => {
|
||||
await expect(oc.tools.invoke("demo")).rejects.toThrow(
|
||||
"oc.tools.invoke is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
await expect(oc.artifacts.list()).rejects.toThrow(
|
||||
"oc.artifacts.list is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
await expect(oc.artifacts.get("artifact_123")).rejects.toThrow(
|
||||
"oc.artifacts.get is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
await expect(oc.artifacts.download("artifact_123")).rejects.toThrow(
|
||||
"oc.artifacts.download is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
await expect(oc.environments.list()).rejects.toThrow(
|
||||
"oc.environments.list is not supported by the current OpenClaw Gateway yet",
|
||||
);
|
||||
|
||||
@@ -20,7 +20,11 @@ export { GatewayClientTransport, isConnectableTransport } from "./transport.js";
|
||||
export type {
|
||||
AgentRunParams,
|
||||
ApprovalMode,
|
||||
ArtifactQuery,
|
||||
ArtifactSummary,
|
||||
ArtifactsDownloadResult,
|
||||
ArtifactsGetResult,
|
||||
ArtifactsListResult,
|
||||
ConnectableOpenClawTransport,
|
||||
EnvironmentSelection,
|
||||
GatewayEvent,
|
||||
|
||||
@@ -62,7 +62,9 @@ export type SDKMessage = {
|
||||
export type ArtifactSummary = {
|
||||
id: string;
|
||||
runId?: string;
|
||||
taskId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
type:
|
||||
| "file"
|
||||
| "patch"
|
||||
@@ -77,10 +79,35 @@ export type ArtifactSummary = {
|
||||
title?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
messageSeq?: number;
|
||||
source?: string;
|
||||
download?: {
|
||||
mode: "bytes" | "url" | "unsupported" | (string & {});
|
||||
};
|
||||
createdAt?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type ArtifactQuery =
|
||||
| { sessionKey: string; runId?: string; taskId?: string }
|
||||
| { runId: string; sessionKey?: string; taskId?: string }
|
||||
| { taskId: string; sessionKey?: string; runId?: string };
|
||||
|
||||
export type ArtifactsListResult = {
|
||||
artifacts: ArtifactSummary[];
|
||||
};
|
||||
|
||||
export type ArtifactsGetResult = {
|
||||
artifact: ArtifactSummary;
|
||||
};
|
||||
|
||||
export type ArtifactsDownloadResult = {
|
||||
artifact: ArtifactSummary;
|
||||
encoding?: "base64";
|
||||
data?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type SDKError = {
|
||||
code?: string;
|
||||
message: string;
|
||||
|
||||
@@ -29127,6 +29127,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
tags: ["advanced", "url-secret"],
|
||||
},
|
||||
},
|
||||
version: "2026.4.27",
|
||||
version: "2026.4.30",
|
||||
generatedAt: "2026-03-22T21:17:33.302Z",
|
||||
};
|
||||
|
||||
@@ -121,6 +121,9 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"talk.config",
|
||||
"agents.files.list",
|
||||
"agents.files.get",
|
||||
"artifacts.list",
|
||||
"artifacts.get",
|
||||
"artifacts.download",
|
||||
],
|
||||
[WRITE_SCOPE]: [
|
||||
"message.action",
|
||||
|
||||
@@ -38,6 +38,17 @@ import {
|
||||
AgentsFilesSetParamsSchema,
|
||||
type AgentsFilesSetResult,
|
||||
AgentsFilesSetResultSchema,
|
||||
type ArtifactsDownloadParams,
|
||||
ArtifactsDownloadParamsSchema,
|
||||
type ArtifactsDownloadResult,
|
||||
type ArtifactsGetParams,
|
||||
ArtifactsGetParamsSchema,
|
||||
type ArtifactsGetResult,
|
||||
type ArtifactsListParams,
|
||||
ArtifactsListParamsSchema,
|
||||
type ArtifactsListResult,
|
||||
type ArtifactSummary,
|
||||
ArtifactSummarySchema,
|
||||
type AgentsListParams,
|
||||
AgentsListParamsSchema,
|
||||
type AgentsListResult,
|
||||
@@ -367,6 +378,12 @@ export const validateAgentsFilesGetParams = ajv.compile<AgentsFilesGetParams>(
|
||||
export const validateAgentsFilesSetParams = ajv.compile<AgentsFilesSetParams>(
|
||||
AgentsFilesSetParamsSchema,
|
||||
);
|
||||
export const validateArtifactsListParams =
|
||||
ajv.compile<ArtifactsListParams>(ArtifactsListParamsSchema);
|
||||
export const validateArtifactsGetParams = ajv.compile<ArtifactsGetParams>(ArtifactsGetParamsSchema);
|
||||
export const validateArtifactsDownloadParams = ajv.compile<ArtifactsDownloadParams>(
|
||||
ArtifactsDownloadParamsSchema,
|
||||
);
|
||||
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
|
||||
NodePairRequestParamsSchema,
|
||||
);
|
||||
@@ -684,6 +701,10 @@ export {
|
||||
SessionsDeleteParamsSchema,
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsUsageParamsSchema,
|
||||
ArtifactSummarySchema,
|
||||
ArtifactsListParamsSchema,
|
||||
ArtifactsGetParamsSchema,
|
||||
ArtifactsDownloadParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigSetParamsSchema,
|
||||
ConfigApplyParamsSchema,
|
||||
@@ -845,6 +866,13 @@ export type {
|
||||
AgentsFilesGetResult,
|
||||
AgentsFilesSetParams,
|
||||
AgentsFilesSetResult,
|
||||
ArtifactSummary,
|
||||
ArtifactsListParams,
|
||||
ArtifactsListResult,
|
||||
ArtifactsGetParams,
|
||||
ArtifactsGetResult,
|
||||
ArtifactsDownloadParams,
|
||||
ArtifactsDownloadResult,
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
CommandsListParams,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./schema/agent.js";
|
||||
export * from "./schema/agents-models-skills.js";
|
||||
export * from "./schema/artifacts.js";
|
||||
export * from "./schema/channels.js";
|
||||
export * from "./schema/commands.js";
|
||||
export * from "./schema/config.js";
|
||||
|
||||
72
src/gateway/protocol/schema/artifacts.ts
Normal file
72
src/gateway/protocol/schema/artifacts.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Type } from "typebox";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
const ArtifactQueryParamsProperties = {
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
runId: Type.Optional(NonEmptyString),
|
||||
taskId: Type.Optional(NonEmptyString),
|
||||
};
|
||||
|
||||
export const ArtifactQueryParamsSchema = Type.Object(ArtifactQueryParamsProperties, {
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export const ArtifactGetParamsSchema = Type.Object(
|
||||
{
|
||||
...ArtifactQueryParamsProperties,
|
||||
artifactId: NonEmptyString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ArtifactSummarySchema = Type.Object(
|
||||
{
|
||||
id: NonEmptyString,
|
||||
type: NonEmptyString,
|
||||
title: NonEmptyString,
|
||||
mimeType: Type.Optional(NonEmptyString),
|
||||
sizeBytes: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
runId: Type.Optional(NonEmptyString),
|
||||
taskId: Type.Optional(NonEmptyString),
|
||||
messageSeq: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
source: Type.Optional(NonEmptyString),
|
||||
download: Type.Object(
|
||||
{
|
||||
mode: Type.Union([Type.Literal("bytes"), Type.Literal("url"), Type.Literal("unsupported")]),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ArtifactsListParamsSchema = ArtifactQueryParamsSchema;
|
||||
|
||||
export const ArtifactsListResultSchema = Type.Object(
|
||||
{
|
||||
artifacts: Type.Array(ArtifactSummarySchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ArtifactsGetParamsSchema = ArtifactGetParamsSchema;
|
||||
|
||||
export const ArtifactsGetResultSchema = Type.Object(
|
||||
{
|
||||
artifact: ArtifactSummarySchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ArtifactsDownloadParamsSchema = ArtifactGetParamsSchema;
|
||||
|
||||
export const ArtifactsDownloadResultSchema = Type.Object(
|
||||
{
|
||||
artifact: ArtifactSummarySchema,
|
||||
encoding: Type.Optional(Type.Literal("base64")),
|
||||
data: Type.Optional(Type.String()),
|
||||
url: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -49,6 +49,15 @@ import {
|
||||
ToolsEffectiveParamsSchema,
|
||||
ToolsEffectiveResultSchema,
|
||||
} from "./agents-models-skills.js";
|
||||
import {
|
||||
ArtifactSummarySchema,
|
||||
ArtifactsDownloadParamsSchema,
|
||||
ArtifactsDownloadResultSchema,
|
||||
ArtifactsGetParamsSchema,
|
||||
ArtifactsGetResultSchema,
|
||||
ArtifactsListParamsSchema,
|
||||
ArtifactsListResultSchema,
|
||||
} from "./artifacts.js";
|
||||
import {
|
||||
ChannelsStartParamsSchema,
|
||||
ChannelsLogoutParamsSchema,
|
||||
@@ -333,6 +342,13 @@ export const ProtocolSchemas = {
|
||||
AgentsFilesGetResult: AgentsFilesGetResultSchema,
|
||||
AgentsFilesSetParams: AgentsFilesSetParamsSchema,
|
||||
AgentsFilesSetResult: AgentsFilesSetResultSchema,
|
||||
ArtifactSummary: ArtifactSummarySchema,
|
||||
ArtifactsListParams: ArtifactsListParamsSchema,
|
||||
ArtifactsListResult: ArtifactsListResultSchema,
|
||||
ArtifactsGetParams: ArtifactsGetParamsSchema,
|
||||
ArtifactsGetResult: ArtifactsGetResultSchema,
|
||||
ArtifactsDownloadParams: ArtifactsDownloadParamsSchema,
|
||||
ArtifactsDownloadResult: ArtifactsDownloadResultSchema,
|
||||
AgentsListParams: AgentsListParamsSchema,
|
||||
AgentsListResult: AgentsListResultSchema,
|
||||
ModelChoice: ModelChoiceSchema,
|
||||
|
||||
@@ -116,6 +116,13 @@ export type AgentsFilesGetParams = SchemaType<"AgentsFilesGetParams">;
|
||||
export type AgentsFilesGetResult = SchemaType<"AgentsFilesGetResult">;
|
||||
export type AgentsFilesSetParams = SchemaType<"AgentsFilesSetParams">;
|
||||
export type AgentsFilesSetResult = SchemaType<"AgentsFilesSetResult">;
|
||||
export type ArtifactSummary = SchemaType<"ArtifactSummary">;
|
||||
export type ArtifactsListParams = SchemaType<"ArtifactsListParams">;
|
||||
export type ArtifactsListResult = SchemaType<"ArtifactsListResult">;
|
||||
export type ArtifactsGetParams = SchemaType<"ArtifactsGetParams">;
|
||||
export type ArtifactsGetResult = SchemaType<"ArtifactsGetResult">;
|
||||
export type ArtifactsDownloadParams = SchemaType<"ArtifactsDownloadParams">;
|
||||
export type ArtifactsDownloadResult = SchemaType<"ArtifactsDownloadResult">;
|
||||
export type AgentsListParams = SchemaType<"AgentsListParams">;
|
||||
export type AgentsListResult = SchemaType<"AgentsListResult">;
|
||||
export type ModelChoice = SchemaType<"ModelChoice">;
|
||||
|
||||
@@ -71,6 +71,9 @@ const BASE_METHODS = [
|
||||
"agents.files.list",
|
||||
"agents.files.get",
|
||||
"agents.files.set",
|
||||
"artifacts.list",
|
||||
"artifacts.get",
|
||||
"artifacts.download",
|
||||
"skills.status",
|
||||
"skills.search",
|
||||
"skills.detail",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ErrorCodes, errorShape } from "./protocol/index.js";
|
||||
import { isRoleAuthorizedForMethod, parseGatewayRole } from "./role-policy.js";
|
||||
import { agentHandlers } from "./server-methods/agent.js";
|
||||
import { agentsHandlers } from "./server-methods/agents.js";
|
||||
import { artifactsHandlers } from "./server-methods/artifacts.js";
|
||||
import { channelsHandlers } from "./server-methods/channels.js";
|
||||
import { chatHandlers } from "./server-methods/chat.js";
|
||||
import { commandsHandlers } from "./server-methods/commands.js";
|
||||
@@ -107,6 +108,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...usageHandlers,
|
||||
...agentHandlers,
|
||||
...agentsHandlers,
|
||||
...artifactsHandlers,
|
||||
};
|
||||
|
||||
export async function handleGatewayRequest(
|
||||
|
||||
397
src/gateway/server-methods/artifacts.test.ts
Normal file
397
src/gateway/server-methods/artifacts.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { artifactsHandlers, collectArtifactsFromMessages } from "./artifacts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
getTaskSessionLookupByIdForStatus: vi.fn(),
|
||||
loadSessionEntry: vi.fn(),
|
||||
readSessionMessages: vi.fn(),
|
||||
resolveSessionKeyForRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../tasks/task-status-access.js", () => ({
|
||||
getTaskSessionLookupByIdForStatus: hoisted.getTaskSessionLookupByIdForStatus,
|
||||
}));
|
||||
|
||||
vi.mock("../session-utils.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../session-utils.js")>("../session-utils.js");
|
||||
return {
|
||||
...actual,
|
||||
loadSessionEntry: hoisted.loadSessionEntry,
|
||||
readSessionMessages: hoisted.readSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../server-session-key.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../server-session-key.js")>(
|
||||
"../server-session-key.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionKeyForRun: hoisted.resolveSessionKeyForRun,
|
||||
};
|
||||
});
|
||||
|
||||
function createResponder() {
|
||||
const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
return {
|
||||
calls,
|
||||
respond: (ok: boolean, payload?: unknown, error?: unknown) => {
|
||||
calls.push({ ok, payload, error });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("artifacts RPC handlers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue(undefined);
|
||||
hoisted.loadSessionEntry.mockReturnValue({
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: { sessionId: "sess-main", sessionFile: "/tmp/sess-main.jsonl" },
|
||||
});
|
||||
hoisted.readSessionMessages.mockReturnValue([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "see attached" },
|
||||
{
|
||||
type: "image",
|
||||
data: "aGVsbG8=",
|
||||
mimeType: "image/png",
|
||||
alt: "result.png",
|
||||
},
|
||||
],
|
||||
__openclaw: { seq: 2 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists stable transcript artifact summaries by sessionKey", async () => {
|
||||
const { calls, respond } = createResponder();
|
||||
|
||||
await artifactsHandlers["artifacts.list"]?.({
|
||||
req: { type: "req", id: "1", method: "artifacts.list", params: {} },
|
||||
params: { sessionKey: "agent:main:main" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {} as never,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.ok).toBe(true);
|
||||
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
|
||||
expect(payload.artifacts).toHaveLength(1);
|
||||
expect(payload.artifacts?.[0]).toMatchObject({
|
||||
type: "image",
|
||||
title: "result.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 5,
|
||||
sessionKey: "agent:main:main",
|
||||
messageSeq: 2,
|
||||
source: "session-transcript",
|
||||
download: { mode: "bytes" },
|
||||
});
|
||||
expect(payload.artifacts?.[0]?.id).toMatch(/^artifact_/);
|
||||
expect(payload.artifacts?.[0]).not.toHaveProperty("data");
|
||||
});
|
||||
|
||||
it("gets and downloads an inline artifact", async () => {
|
||||
const listed = collectArtifactsFromMessages({
|
||||
sessionKey: "agent:main:main",
|
||||
messages: hoisted.readSessionMessages(),
|
||||
});
|
||||
const artifactId = listed[0]?.id;
|
||||
expect(artifactId).toBeTruthy();
|
||||
|
||||
const get = createResponder();
|
||||
await artifactsHandlers["artifacts.get"]?.({
|
||||
req: { type: "req", id: "2", method: "artifacts.get", params: {} },
|
||||
params: { sessionKey: "agent:main:main", artifactId },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: get.respond,
|
||||
context: {} as never,
|
||||
});
|
||||
expect(get.calls[0]?.ok).toBe(true);
|
||||
expect(get.calls[0]?.payload).toMatchObject({
|
||||
artifact: { id: artifactId, download: { mode: "bytes" } },
|
||||
});
|
||||
|
||||
const download = createResponder();
|
||||
await artifactsHandlers["artifacts.download"]?.({
|
||||
req: { type: "req", id: "3", method: "artifacts.download", params: {} },
|
||||
params: { sessionKey: "agent:main:main", artifactId },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: download.respond,
|
||||
context: {} as never,
|
||||
});
|
||||
expect(download.calls[0]?.ok).toBe(true);
|
||||
expect(download.calls[0]?.payload).toMatchObject({
|
||||
encoding: "base64",
|
||||
data: "aGVsbG8=",
|
||||
artifact: { id: artifactId },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves runId queries through the gateway run-to-session lookup", async () => {
|
||||
hoisted.resolveSessionKeyForRun.mockReturnValue("agent:main:main");
|
||||
hoisted.readSessionMessages.mockReturnValue([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "image", data: "aGVsbG8=", alt: "run-result.png" }],
|
||||
__openclaw: { seq: 2, runId: "run-1" },
|
||||
},
|
||||
]);
|
||||
const { calls, respond } = createResponder();
|
||||
|
||||
await artifactsHandlers["artifacts.list"]?.({
|
||||
req: { type: "req", id: "4", method: "artifacts.list", params: {} },
|
||||
params: { runId: "run-1" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {} as never,
|
||||
});
|
||||
|
||||
expect(calls[0]?.ok).toBe(true);
|
||||
expect(hoisted.resolveSessionKeyForRun).toHaveBeenCalledWith("run-1");
|
||||
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
|
||||
expect(payload.artifacts?.[0]).toMatchObject({ runId: "run-1" });
|
||||
});
|
||||
|
||||
it("resolves taskId queries through task status access and filters artifacts by messageTaskId", async () => {
|
||||
hoisted.getTaskSessionLookupByIdForStatus.mockReturnValue({
|
||||
requesterSessionKey: "agent:main:main",
|
||||
runId: "run-for-task-1",
|
||||
});
|
||||
hoisted.readSessionMessages.mockReturnValue([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "image", data: "dGFyZ2V0", alt: "task-result.png" }],
|
||||
__openclaw: { seq: 2, messageTaskId: "task-1" },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "image", data: "b3RoZXI=", alt: "other-task.png" }],
|
||||
__openclaw: { seq: 3, messageTaskId: "task-2" },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "image", data: "dW50YWdnZWQ=", alt: "untagged.png" }],
|
||||
__openclaw: { seq: 4 },
|
||||
},
|
||||
]);
|
||||
|
||||
const list = createResponder();
|
||||
await artifactsHandlers["artifacts.list"]?.({
|
||||
req: { type: "req", id: "task-list", method: "artifacts.list", params: {} },
|
||||
params: { taskId: "task-1" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: list.respond,
|
||||
context: {} as never,
|
||||
});
|
||||
|
||||
expect(list.calls[0]?.ok).toBe(true);
|
||||
expect(hoisted.getTaskSessionLookupByIdForStatus).toHaveBeenCalledWith("task-1");
|
||||
expect(hoisted.resolveSessionKeyForRun).not.toHaveBeenCalled();
|
||||
expect(hoisted.loadSessionEntry).toHaveBeenCalledWith("agent:main:main");
|
||||
const listPayload = list.calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
|
||||
expect(listPayload.artifacts).toHaveLength(1);
|
||||
expect(listPayload.artifacts?.[0]).toMatchObject({
|
||||
taskId: "task-1",
|
||||
title: "task-result.png",
|
||||
});
|
||||
|
||||
const artifactId = listPayload.artifacts?.[0]?.id as string | undefined;
|
||||
expect(artifactId).toBeTruthy();
|
||||
|
||||
const get = createResponder();
|
||||
await artifactsHandlers["artifacts.get"]?.({
|
||||
req: { type: "req", id: "task-get", method: "artifacts.get", params: {} },
|
||||
params: { taskId: "task-1", artifactId },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: get.respond,
|
||||
context: {} as never,
|
||||
});
|
||||
expect(get.calls[0]?.ok).toBe(true);
|
||||
expect(get.calls[0]?.payload).toMatchObject({
|
||||
artifact: { id: artifactId, taskId: "task-1", title: "task-result.png" },
|
||||
});
|
||||
|
||||
const download = createResponder();
|
||||
await artifactsHandlers["artifacts.download"]?.({
|
||||
req: { type: "req", id: "task-download", method: "artifacts.download", params: {} },
|
||||
params: { taskId: "task-1", artifactId },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: download.respond,
|
||||
context: {} as never,
|
||||
});
|
||||
expect(download.calls[0]?.ok).toBe(true);
|
||||
expect(download.calls[0]?.payload).toMatchObject({
|
||||
encoding: "base64",
|
||||
data: "dGFyZ2V0",
|
||||
artifact: { id: artifactId, taskId: "task-1", title: "task-result.png" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not return untagged session artifacts for scoped runId queries", async () => {
|
||||
hoisted.resolveSessionKeyForRun.mockReturnValue("agent:main:main");
|
||||
const { calls, respond } = createResponder();
|
||||
|
||||
await artifactsHandlers["artifacts.list"]?.({
|
||||
req: { type: "req", id: "run-scope", method: "artifacts.list", params: {} },
|
||||
params: { runId: "run-1" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {} as never,
|
||||
});
|
||||
|
||||
expect(calls[0]?.ok).toBe(true);
|
||||
expect(calls[0]?.payload).toEqual({ artifacts: [] });
|
||||
});
|
||||
|
||||
it("discovers transcript image_url data blocks", async () => {
|
||||
hoisted.readSessionMessages.mockReturnValue([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_image",
|
||||
image_url: "data:image/png;base64,aGVsbG8=",
|
||||
alt: "uploaded.png",
|
||||
},
|
||||
],
|
||||
__openclaw: { seq: 3 },
|
||||
},
|
||||
]);
|
||||
const { calls, respond } = createResponder();
|
||||
|
||||
await artifactsHandlers["artifacts.list"]?.({
|
||||
req: { type: "req", id: "image-url", method: "artifacts.list", params: {} },
|
||||
params: { sessionKey: "agent:main:main" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {} as never,
|
||||
});
|
||||
|
||||
expect(calls[0]?.ok).toBe(true);
|
||||
const payload = calls[0]?.payload as { artifacts?: Array<Record<string, unknown>> };
|
||||
expect(payload.artifacts).toHaveLength(1);
|
||||
expect(payload.artifacts?.[0]).toMatchObject({
|
||||
type: "image",
|
||||
title: "uploaded.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 5,
|
||||
download: { mode: "bytes" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats transcript non-base64 data URLs as unsupported downloads", () => {
|
||||
const artifacts = collectArtifactsFromMessages({
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_image",
|
||||
image_url: "data:text/plain,hello",
|
||||
alt: "uploaded.txt",
|
||||
},
|
||||
],
|
||||
__openclaw: { seq: 4 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
expect(artifacts[0]).toMatchObject({
|
||||
type: "image",
|
||||
title: "uploaded.txt",
|
||||
download: { mode: "unsupported" },
|
||||
});
|
||||
expect(artifacts[0]?.download).not.toHaveProperty("encoding", "base64");
|
||||
});
|
||||
|
||||
it("treats non-base64 data URLs in the content field as unsupported downloads", () => {
|
||||
const artifacts = collectArtifactsFromMessages({
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
content: "data:text/plain,hello",
|
||||
title: "plain.txt",
|
||||
},
|
||||
],
|
||||
__openclaw: { seq: 5 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
expect(artifacts[0]).toMatchObject({
|
||||
title: "plain.txt",
|
||||
download: { mode: "unsupported" },
|
||||
});
|
||||
expect(artifacts[0]).not.toHaveProperty("data");
|
||||
});
|
||||
|
||||
it("treats unsafe artifact URLs as unsupported downloads", async () => {
|
||||
const artifacts = collectArtifactsFromMessages({
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "file", title: "secret.txt", url: "file:///etc/passwd" }],
|
||||
__openclaw: { seq: 4 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(artifacts[0]).toMatchObject({
|
||||
title: "secret.txt",
|
||||
download: { mode: "unsupported" },
|
||||
});
|
||||
expect(artifacts[0]).not.toHaveProperty("url");
|
||||
});
|
||||
|
||||
it("returns typed errors for missing query scope and missing artifacts", async () => {
|
||||
const missingScope = createResponder();
|
||||
await artifactsHandlers["artifacts.list"]?.({
|
||||
req: { type: "req", id: "5", method: "artifacts.list", params: {} },
|
||||
params: {},
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: missingScope.respond,
|
||||
context: {} as never,
|
||||
});
|
||||
expect(missingScope.calls[0]?.ok).toBe(false);
|
||||
expect(missingScope.calls[0]?.error).toMatchObject({
|
||||
details: { type: "artifact_query_unsupported" },
|
||||
});
|
||||
|
||||
const notFound = createResponder();
|
||||
await artifactsHandlers["artifacts.get"]?.({
|
||||
req: { type: "req", id: "6", method: "artifacts.get", params: {} },
|
||||
params: { sessionKey: "agent:main:main", artifactId: "artifact_missing" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: notFound.respond,
|
||||
context: {} as never,
|
||||
});
|
||||
expect(notFound.calls[0]?.ok).toBe(false);
|
||||
expect(notFound.calls[0]?.error).toMatchObject({
|
||||
details: { type: "artifact_not_found", artifactId: "artifact_missing" },
|
||||
});
|
||||
});
|
||||
});
|
||||
420
src/gateway/server-methods/artifacts.ts
Normal file
420
src/gateway/server-methods/artifacts.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { getTaskSessionLookupByIdForStatus } from "../../tasks/task-status-access.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
type ArtifactSummary,
|
||||
type ArtifactsGetParams,
|
||||
validateArtifactsDownloadParams,
|
||||
validateArtifactsGetParams,
|
||||
validateArtifactsListParams,
|
||||
} from "../protocol/index.js";
|
||||
import { resolveSessionKeyForRun } from "../server-session-key.js";
|
||||
import { loadSessionEntry, readSessionMessages } from "../session-utils.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
type ArtifactDownloadMode = ArtifactSummary["download"]["mode"];
|
||||
|
||||
type ArtifactRecord = ArtifactSummary & {
|
||||
data?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type ArtifactQuery = {
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
function artifactError(type: string, message: string, details?: Record<string, unknown>) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, message, {
|
||||
details: {
|
||||
type,
|
||||
...details,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeArtifactType(value: string): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "image" || normalized === "input_image" || normalized === "image_url") {
|
||||
return "image";
|
||||
}
|
||||
if (normalized === "audio" || normalized === "input_audio") {
|
||||
return "audio";
|
||||
}
|
||||
if (normalized === "file" || normalized === "input_file") {
|
||||
return "file";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
function mimeFromDataUrl(value: string): string | undefined {
|
||||
const match = /^data:([^;,]+)(?:;[^,]*)?,/i.exec(value.trim());
|
||||
return match?.[1]?.toLowerCase();
|
||||
}
|
||||
|
||||
function base64FromDataUrl(value: string): string | undefined {
|
||||
const match = /^data:[^,]*;base64,(.*)$/is.exec(value.trim());
|
||||
return match?.[1]?.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
function estimateBase64Size(value: string | undefined): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return Buffer.from(value, "base64").byteLength;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function mediaUrlValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return asNonEmptyString(value);
|
||||
}
|
||||
const record = asRecord(value);
|
||||
return asNonEmptyString(record?.url);
|
||||
}
|
||||
|
||||
function isSafeDownloadUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || /^data:/i.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return !trimmed.startsWith("//") && trimmed.startsWith("/api/");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function artifactId(parts: {
|
||||
sessionKey: string;
|
||||
messageSeq: number;
|
||||
contentIndex: number;
|
||||
title: string;
|
||||
type: string;
|
||||
}): string {
|
||||
const hash = createHash("sha256")
|
||||
.update(
|
||||
`${parts.sessionKey}\0${parts.messageSeq}\0${parts.contentIndex}\0${parts.type}\0${parts.title}`,
|
||||
)
|
||||
.digest("base64url")
|
||||
.slice(0, 18);
|
||||
return `artifact_${hash}`;
|
||||
}
|
||||
|
||||
function resolveMessageSeq(message: Record<string, unknown>, fallback: number): number {
|
||||
const meta = asRecord(message.__openclaw);
|
||||
const seq = meta?.seq;
|
||||
return typeof seq === "number" && Number.isInteger(seq) && seq > 0 ? seq : fallback;
|
||||
}
|
||||
|
||||
function resolveMessageRunId(message: Record<string, unknown>): string | undefined {
|
||||
const meta = asRecord(message.__openclaw);
|
||||
return asNonEmptyString(meta?.runId) ?? asNonEmptyString(message.runId);
|
||||
}
|
||||
|
||||
function resolveMessageTaskId(message: Record<string, unknown>): string | undefined {
|
||||
const meta = asRecord(message.__openclaw);
|
||||
return (
|
||||
asNonEmptyString(meta?.messageTaskId) ??
|
||||
asNonEmptyString(meta?.taskId) ??
|
||||
asNonEmptyString(message.messageTaskId) ??
|
||||
asNonEmptyString(message.taskId)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBlockDownload(block: Record<string, unknown>): {
|
||||
mode: ArtifactDownloadMode;
|
||||
data?: string;
|
||||
url?: string;
|
||||
mimeType?: string;
|
||||
sizeBytes?: number;
|
||||
} {
|
||||
const data = asNonEmptyString(block.data);
|
||||
const content = asNonEmptyString(block.content);
|
||||
const url = asNonEmptyString(block.url) ?? asNonEmptyString(block.openUrl);
|
||||
const imageUrl = mediaUrlValue(block.image_url);
|
||||
const audioUrl = asNonEmptyString(block.audio_url);
|
||||
const source = asRecord(block.source);
|
||||
const sourceData = asNonEmptyString(source?.data);
|
||||
const sourceUrl = asNonEmptyString(source?.url);
|
||||
const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, content, sourceData].find(
|
||||
(value) => typeof value === "string" && /^data:/i.test(value),
|
||||
);
|
||||
const base64FromDetectedDataUrl = dataUrl ? base64FromDataUrl(dataUrl) : undefined;
|
||||
const directBase64 = [data, sourceData, content].find(
|
||||
(value) => typeof value === "string" && !/^data:/i.test(value),
|
||||
);
|
||||
const base64 = base64FromDetectedDataUrl ?? directBase64;
|
||||
const remoteUrl = [url, sourceUrl, imageUrl, audioUrl].find(
|
||||
(value) => typeof value === "string" && isSafeDownloadUrl(value),
|
||||
);
|
||||
const mimeType =
|
||||
asNonEmptyString(block.mimeType) ??
|
||||
asNonEmptyString(block.media_type) ??
|
||||
asNonEmptyString(source?.media_type) ??
|
||||
asNonEmptyString(source?.mimeType) ??
|
||||
(dataUrl ? mimeFromDataUrl(dataUrl) : undefined);
|
||||
const explicitSize = block.sizeBytes ?? source?.sizeBytes;
|
||||
const sizeBytes =
|
||||
typeof explicitSize === "number" && Number.isFinite(explicitSize) && explicitSize >= 0
|
||||
? Math.floor(explicitSize)
|
||||
: estimateBase64Size(base64);
|
||||
if (base64) {
|
||||
return { mode: "bytes", data: base64, mimeType, sizeBytes };
|
||||
}
|
||||
if (remoteUrl) {
|
||||
return { mode: "url", url: remoteUrl, mimeType, sizeBytes };
|
||||
}
|
||||
return { mode: "unsupported", mimeType, sizeBytes };
|
||||
}
|
||||
|
||||
function isArtifactBlock(block: Record<string, unknown>): boolean {
|
||||
const type = asNonEmptyString(block.type)?.toLowerCase();
|
||||
if (
|
||||
type === "image" ||
|
||||
type === "audio" ||
|
||||
type === "file" ||
|
||||
type === "input_image" ||
|
||||
type === "input_audio" ||
|
||||
type === "input_file" ||
|
||||
type === "image_url"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
block.url || block.openUrl || block.data || block.source || block.image_url || block.audio_url,
|
||||
);
|
||||
}
|
||||
|
||||
export function collectArtifactsFromMessages(params: {
|
||||
messages: unknown[];
|
||||
sessionKey: string;
|
||||
runId?: string;
|
||||
taskId?: string;
|
||||
}): ArtifactRecord[] {
|
||||
const artifacts: ArtifactRecord[] = [];
|
||||
let messageFallbackSeq = 0;
|
||||
for (const message of params.messages) {
|
||||
const msg = asRecord(message);
|
||||
if (!msg) {
|
||||
continue;
|
||||
}
|
||||
messageFallbackSeq += 1;
|
||||
const messageSeq = resolveMessageSeq(msg, messageFallbackSeq);
|
||||
const messageRunId = resolveMessageRunId(msg);
|
||||
const messageTaskId = resolveMessageTaskId(msg);
|
||||
if (params.runId && messageRunId !== params.runId) {
|
||||
continue;
|
||||
}
|
||||
if (params.taskId && messageTaskId !== params.taskId) {
|
||||
continue;
|
||||
}
|
||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
|
||||
const block = asRecord(content[contentIndex]);
|
||||
if (!block || !isArtifactBlock(block)) {
|
||||
continue;
|
||||
}
|
||||
const type = normalizeArtifactType(asNonEmptyString(block.type) ?? "file");
|
||||
const title =
|
||||
asNonEmptyString(block.title) ??
|
||||
asNonEmptyString(block.fileName) ??
|
||||
asNonEmptyString(block.filename) ??
|
||||
asNonEmptyString(block.alt) ??
|
||||
`${type} ${artifacts.length + 1}`;
|
||||
const download = resolveBlockDownload(block);
|
||||
const summary: ArtifactRecord = {
|
||||
id: artifactId({
|
||||
sessionKey: params.sessionKey,
|
||||
messageSeq,
|
||||
contentIndex,
|
||||
title,
|
||||
type,
|
||||
}),
|
||||
type,
|
||||
title,
|
||||
...(download.mimeType ? { mimeType: download.mimeType } : {}),
|
||||
...(download.sizeBytes !== undefined ? { sizeBytes: download.sizeBytes } : {}),
|
||||
sessionKey: params.sessionKey,
|
||||
...(messageRunId ? { runId: messageRunId } : {}),
|
||||
...(messageTaskId ? { taskId: messageTaskId } : {}),
|
||||
messageSeq,
|
||||
source: "session-transcript",
|
||||
download: { mode: download.mode },
|
||||
...(download.data ? { data: download.data } : {}),
|
||||
...(download.url ? { url: download.url } : {}),
|
||||
};
|
||||
artifacts.push(summary);
|
||||
}
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
function resolveQuerySessionKey(query: ArtifactQuery): string | undefined {
|
||||
if (query.sessionKey) {
|
||||
return query.sessionKey;
|
||||
}
|
||||
if (query.runId) {
|
||||
return resolveSessionKeyForRun(query.runId);
|
||||
}
|
||||
if (query.taskId) {
|
||||
const task = getTaskSessionLookupByIdForStatus(query.taskId);
|
||||
const requesterSessionKey = asNonEmptyString(task?.requesterSessionKey);
|
||||
if (requesterSessionKey) {
|
||||
return requesterSessionKey;
|
||||
}
|
||||
const runId = asNonEmptyString(task?.runId);
|
||||
return runId ? resolveSessionKeyForRun(runId) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadArtifacts(query: ArtifactQuery): { artifacts: ArtifactRecord[]; sessionKey?: string } {
|
||||
const sessionKey = resolveQuerySessionKey(query);
|
||||
if (!sessionKey) {
|
||||
return { artifacts: [] };
|
||||
}
|
||||
const { storePath, entry } = loadSessionEntry(sessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
const messages =
|
||||
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
|
||||
return {
|
||||
sessionKey,
|
||||
artifacts: collectArtifactsFromMessages({
|
||||
messages,
|
||||
sessionKey,
|
||||
runId: query.runId,
|
||||
taskId: query.taskId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function requireQueryable(params: ArtifactQuery, respond: RespondFn): boolean {
|
||||
if (params.sessionKey || params.runId || params.taskId) {
|
||||
return true;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
artifactError(
|
||||
"artifact_query_unsupported",
|
||||
"artifacts require one of sessionKey, runId, or taskId",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function findArtifact(params: ArtifactsGetParams): {
|
||||
artifact?: ArtifactRecord;
|
||||
sessionKey?: string;
|
||||
} {
|
||||
const loaded = loadArtifacts(params);
|
||||
return {
|
||||
sessionKey: loaded.sessionKey,
|
||||
artifact: loaded.artifacts.find((artifact) => artifact.id === params.artifactId),
|
||||
};
|
||||
}
|
||||
|
||||
function toSummary(artifact: ArtifactRecord): ArtifactSummary {
|
||||
const { data: _data, url: _url, ...summary } = artifact;
|
||||
return summary;
|
||||
}
|
||||
|
||||
export const artifactsHandlers: GatewayRequestHandlers = {
|
||||
"artifacts.list": ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validateArtifactsListParams, "artifacts.list", respond)) {
|
||||
return;
|
||||
}
|
||||
if (!requireQueryable(params, respond)) {
|
||||
return;
|
||||
}
|
||||
const { artifacts, sessionKey } = loadArtifacts(params);
|
||||
if (!sessionKey && (params.runId || params.taskId)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
artifactError("artifact_scope_not_found", "no session found for artifact query"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { artifacts: artifacts.map(toSummary) });
|
||||
},
|
||||
"artifacts.get": ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validateArtifactsGetParams, "artifacts.get", respond)) {
|
||||
return;
|
||||
}
|
||||
if (!requireQueryable(params, respond)) {
|
||||
return;
|
||||
}
|
||||
const { artifact } = findArtifact(params);
|
||||
if (!artifact) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
artifactError("artifact_not_found", "artifact not found", {
|
||||
artifactId: params.artifactId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { artifact: toSummary(artifact) });
|
||||
},
|
||||
"artifacts.download": ({ params, respond }) => {
|
||||
if (
|
||||
!assertValidParams(params, validateArtifactsDownloadParams, "artifacts.download", respond)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!requireQueryable(params, respond)) {
|
||||
return;
|
||||
}
|
||||
const { artifact } = findArtifact(params);
|
||||
if (!artifact) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
artifactError("artifact_not_found", "artifact not found", {
|
||||
artifactId: params.artifactId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (artifact.download.mode === "unsupported") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
artifactError("artifact_download_unsupported", "artifact download is unsupported", {
|
||||
artifactId: artifact.id,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, {
|
||||
artifact: toSummary(artifact),
|
||||
...(artifact.download.mode === "bytes"
|
||||
? { encoding: "base64" as const, data: artifact.data }
|
||||
: {}),
|
||||
...(artifact.download.mode === "url" ? { url: artifact.url } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,18 @@
|
||||
import { listTasksForAgentId, listTasksForSessionKey } from "./task-registry.js";
|
||||
import { getTaskById, listTasksForAgentId, listTasksForSessionKey } from "./task-registry.js";
|
||||
import type { TaskRecord } from "./task-registry.types.js";
|
||||
|
||||
export function getTaskSessionLookupByIdForStatus(
|
||||
taskId: string,
|
||||
): Pick<TaskRecord, "requesterSessionKey" | "runId"> | undefined {
|
||||
const task = getTaskById(taskId);
|
||||
return task
|
||||
? {
|
||||
requesterSessionKey: task.requesterSessionKey,
|
||||
...(task.runId ? { runId: task.runId } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function listTasksForSessionKeyForStatus(sessionKey: string): TaskRecord[] {
|
||||
return listTasksForSessionKey(sessionKey);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user