runtimeuse 0.2.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.
Files changed (120) hide show
  1. package/.env.example +4 -0
  2. package/README.md +222 -0
  3. package/dist/agent-handler.d.ts +26 -0
  4. package/dist/agent-handler.d.ts.map +1 -0
  5. package/dist/agent-handler.js +2 -0
  6. package/dist/agent-handler.js.map +1 -0
  7. package/dist/artifact-manager.d.ts +27 -0
  8. package/dist/artifact-manager.d.ts.map +1 -0
  9. package/dist/artifact-manager.js +125 -0
  10. package/dist/artifact-manager.js.map +1 -0
  11. package/dist/artifact-manager.test.d.ts +2 -0
  12. package/dist/artifact-manager.test.d.ts.map +1 -0
  13. package/dist/artifact-manager.test.js +251 -0
  14. package/dist/artifact-manager.test.js.map +1 -0
  15. package/dist/claude-handler.d.ts +3 -0
  16. package/dist/claude-handler.d.ts.map +1 -0
  17. package/dist/claude-handler.js +76 -0
  18. package/dist/claude-handler.js.map +1 -0
  19. package/dist/cli.d.ts +3 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +87 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/command-handler.d.ts +22 -0
  24. package/dist/command-handler.d.ts.map +1 -0
  25. package/dist/command-handler.js +75 -0
  26. package/dist/command-handler.js.map +1 -0
  27. package/dist/command-handler.test.d.ts +2 -0
  28. package/dist/command-handler.test.d.ts.map +1 -0
  29. package/dist/command-handler.test.js +267 -0
  30. package/dist/command-handler.test.js.map +1 -0
  31. package/dist/constants.d.ts +3 -0
  32. package/dist/constants.d.ts.map +1 -0
  33. package/dist/constants.js +13 -0
  34. package/dist/constants.js.map +1 -0
  35. package/dist/default-handler.d.ts +3 -0
  36. package/dist/default-handler.d.ts.map +1 -0
  37. package/dist/default-handler.js +76 -0
  38. package/dist/default-handler.js.map +1 -0
  39. package/dist/download-handler.d.ts +8 -0
  40. package/dist/download-handler.d.ts.map +1 -0
  41. package/dist/download-handler.js +36 -0
  42. package/dist/download-handler.js.map +1 -0
  43. package/dist/download-handler.test.d.ts +2 -0
  44. package/dist/download-handler.test.d.ts.map +1 -0
  45. package/dist/download-handler.test.js +123 -0
  46. package/dist/download-handler.test.js.map +1 -0
  47. package/dist/index.d.ts +20 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +21 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/logger.d.ts +8 -0
  52. package/dist/logger.d.ts.map +1 -0
  53. package/dist/logger.js +14 -0
  54. package/dist/logger.js.map +1 -0
  55. package/dist/openai-handler.d.ts +3 -0
  56. package/dist/openai-handler.d.ts.map +1 -0
  57. package/dist/openai-handler.js +86 -0
  58. package/dist/openai-handler.js.map +1 -0
  59. package/dist/server.d.ts +21 -0
  60. package/dist/server.d.ts.map +1 -0
  61. package/dist/server.js +52 -0
  62. package/dist/server.js.map +1 -0
  63. package/dist/session.d.ts +29 -0
  64. package/dist/session.d.ts.map +1 -0
  65. package/dist/session.js +244 -0
  66. package/dist/session.js.map +1 -0
  67. package/dist/session.test.d.ts +2 -0
  68. package/dist/session.test.d.ts.map +1 -0
  69. package/dist/session.test.js +339 -0
  70. package/dist/session.test.js.map +1 -0
  71. package/dist/storage.d.ts +3 -0
  72. package/dist/storage.d.ts.map +1 -0
  73. package/dist/storage.js +21 -0
  74. package/dist/storage.js.map +1 -0
  75. package/dist/types.d.ts +62 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +2 -0
  78. package/dist/types.js.map +1 -0
  79. package/dist/upload-tracker.d.ts +10 -0
  80. package/dist/upload-tracker.d.ts.map +1 -0
  81. package/dist/upload-tracker.js +27 -0
  82. package/dist/upload-tracker.js.map +1 -0
  83. package/dist/upload-tracker.test.d.ts +2 -0
  84. package/dist/upload-tracker.test.d.ts.map +1 -0
  85. package/dist/upload-tracker.test.js +89 -0
  86. package/dist/upload-tracker.test.js.map +1 -0
  87. package/dist/utils.d.ts +7 -0
  88. package/dist/utils.d.ts.map +1 -0
  89. package/dist/utils.js +32 -0
  90. package/dist/utils.js.map +1 -0
  91. package/dist/utils.test.d.ts +2 -0
  92. package/dist/utils.test.d.ts.map +1 -0
  93. package/dist/utils.test.js +92 -0
  94. package/dist/utils.test.js.map +1 -0
  95. package/package.json +40 -0
  96. package/scripts/dev-publish.sh +45 -0
  97. package/src/agent-handler.ts +26 -0
  98. package/src/artifact-manager.test.ts +320 -0
  99. package/src/artifact-manager.ts +170 -0
  100. package/src/claude-handler.ts +95 -0
  101. package/src/cli.ts +107 -0
  102. package/src/command-handler.test.ts +507 -0
  103. package/src/command-handler.ts +102 -0
  104. package/src/constants.ts +12 -0
  105. package/src/download-handler.test.ts +183 -0
  106. package/src/download-handler.ts +45 -0
  107. package/src/index.ts +59 -0
  108. package/src/logger.ts +20 -0
  109. package/src/openai-handler.ts +120 -0
  110. package/src/server.ts +68 -0
  111. package/src/session.test.ts +448 -0
  112. package/src/session.ts +319 -0
  113. package/src/storage.ts +28 -0
  114. package/src/types.ts +101 -0
  115. package/src/upload-tracker.test.ts +112 -0
  116. package/src/upload-tracker.ts +30 -0
  117. package/src/utils.test.ts +120 -0
  118. package/src/utils.ts +35 -0
  119. package/tsconfig.json +20 -0
  120. package/vitest.config.ts +7 -0
package/src/session.ts ADDED
@@ -0,0 +1,319 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { WebSocket } from "ws";
4
+
5
+ import type { AgentHandler, MessageSender } from "./agent-handler.js";
6
+ import { ArtifactManager } from "./artifact-manager.js";
7
+ import type { UploadTracker } from "./upload-tracker.js";
8
+ import type {
9
+ InvocationMessage,
10
+ IncomingMessage,
11
+ OutgoingMessage,
12
+ ResultMessage,
13
+ } from "./types.js";
14
+ import { sleep } from "./utils.js";
15
+ import { createLogger, defaultLogger, type Logger } from "./logger.js";
16
+ import { DEFAULT_ARTIFACTS_DIR } from "./constants.js";
17
+ import CommandHandler from "./command-handler.js";
18
+ import DownloadHandler from "./download-handler.js";
19
+
20
+ export interface SessionConfig {
21
+ handler: AgentHandler;
22
+ uploadTracker: UploadTracker;
23
+ defaultModel?: string;
24
+ uploadTimeoutMs?: number;
25
+ artifactWaitMs?: number;
26
+ postInvocationDelayMs?: number;
27
+ logger?: Logger;
28
+ }
29
+
30
+ export class WebSocketSession {
31
+ private readonly ws: WebSocket;
32
+ private readonly config: SessionConfig;
33
+ private readonly abortController = new AbortController();
34
+ private artifactManager: ArtifactManager | null = null;
35
+ private invocationReceived = false;
36
+ private logger: Logger;
37
+ private readonly downloadHandler: DownloadHandler;
38
+
39
+ constructor(ws: WebSocket, config: SessionConfig) {
40
+ this.ws = ws;
41
+ this.config = config;
42
+ this.logger = config.logger ?? defaultLogger;
43
+ this.downloadHandler = new DownloadHandler(this.logger);
44
+ }
45
+
46
+ run(): Promise<void> {
47
+ return new Promise<void>((resolve) => {
48
+ this.ws.on("message", async (rawData) => {
49
+ this.logger.log("Received new WS message");
50
+ try {
51
+ const message: IncomingMessage = JSON.parse(rawData.toString());
52
+ await this.handleMessage(message, resolve);
53
+ } catch (error) {
54
+ this.logger.error("Error processing message:", error);
55
+ this.send({
56
+ message_type: "error_message",
57
+ error: String(error),
58
+ metadata: {},
59
+ });
60
+ }
61
+ });
62
+
63
+ this.ws.on("close", async () => {
64
+ this.logger.log("WebSocket connection closed");
65
+ this.abortController.abort();
66
+ await this.artifactManager?.stopWatching();
67
+ await this.config.uploadTracker.waitForAll(
68
+ this.config.uploadTimeoutMs ?? 30_000,
69
+ );
70
+ resolve();
71
+ });
72
+
73
+ this.ws.on("error", (error) => {
74
+ this.logger.error("WebSocket error:", error);
75
+ });
76
+ });
77
+ }
78
+
79
+ private async handleMessage(
80
+ message: IncomingMessage,
81
+ resolve: () => void,
82
+ ): Promise<void> {
83
+ if (
84
+ !this.invocationReceived &&
85
+ message.message_type !== "invocation_message"
86
+ ) {
87
+ throw new Error(
88
+ "Received non-invocation message before invocation message! Received: " +
89
+ JSON.stringify(message),
90
+ );
91
+ }
92
+
93
+ switch (message.message_type) {
94
+ case "artifact_upload_response_message":
95
+ try {
96
+ await this.artifactManager?.handleUploadResponse(message);
97
+ } catch (error) {
98
+ this.logger.error("Error uploading artifact:", error);
99
+ this.send({
100
+ message_type: "error_message",
101
+ error: String(error),
102
+ metadata: {},
103
+ });
104
+ }
105
+ break;
106
+
107
+ case "cancel_message":
108
+ this.logger.log("Received cancel message. Aborting agent execution...");
109
+ this.abortController.abort();
110
+ this.ws.close();
111
+ break;
112
+
113
+ case "invocation_message":
114
+ if (this.invocationReceived) {
115
+ throw new Error("Received multiple invocation messages!");
116
+ }
117
+ this.invocationReceived = true;
118
+ await this.executeInvocation(message);
119
+ if (process.env.NODE_ENV !== "test") {
120
+ await sleep(this.config.postInvocationDelayMs ?? 3_000);
121
+ }
122
+ await this.finalize();
123
+ resolve();
124
+ break;
125
+ }
126
+ }
127
+
128
+ private async executeInvocation(message: InvocationMessage): Promise<void> {
129
+ this.logger = createLogger(message.source_id);
130
+ this.config.uploadTracker.setLogger(this.logger);
131
+
132
+ const artifactsDir = message.artifacts_dir ?? DEFAULT_ARTIFACTS_DIR;
133
+ this.artifactManager = new ArtifactManager({
134
+ artifactsDir,
135
+ uploadTracker: this.config.uploadTracker,
136
+ send: (msg) => this.send(msg),
137
+ });
138
+ this.artifactManager.setLogger(this.logger);
139
+
140
+ const outputFormat = JSON.parse(message.output_format_json_schema_str) as {
141
+ type: "json_schema";
142
+ schema: Record<string, unknown>;
143
+ };
144
+ const model =
145
+ message.preferred_model ?? this.config.defaultModel ?? "default";
146
+
147
+ this.logger.log(`Received invocation: model=${model}`);
148
+
149
+ try {
150
+ if (message.runtime_environment_downloadables) {
151
+ this.logger.log("Downloading runtime environment downloadables...");
152
+ for (const downloadable of message.runtime_environment_downloadables) {
153
+ await this.downloadHandler.download(
154
+ downloadable.download_url,
155
+ downloadable.working_dir,
156
+ );
157
+ }
158
+ }
159
+ if (message.pre_agent_invocation_commands) {
160
+ const stdoutFile = path.join(artifactsDir, `cmd-stdout.out`);
161
+ const stderrFile = path.join(artifactsDir, `cmd-stderr.out`);
162
+ const stdoutStream = fs.createWriteStream(stdoutFile);
163
+ const stderrStream = fs.createWriteStream(stderrFile);
164
+
165
+ for (const command of message.pre_agent_invocation_commands) {
166
+ try {
167
+ this.logger.log(
168
+ `Executing command: ${command.command} in directory: ${command.cwd}`,
169
+ );
170
+ const commandHandler = new CommandHandler(
171
+ command,
172
+ this.logger,
173
+ stdoutStream,
174
+ stderrStream,
175
+ this.abortController,
176
+ (stdout) =>
177
+ this.send({
178
+ message_type: "assistant_message",
179
+ text_blocks: [stdout],
180
+ }),
181
+ (stderr) =>
182
+ this.send({
183
+ message_type: "assistant_message",
184
+ text_blocks: [stderr],
185
+ }),
186
+ );
187
+ try {
188
+ const result = await commandHandler.execute();
189
+ if (result.exitCode === 0) {
190
+ this.send({
191
+ message_type: "result_message",
192
+ structured_output: {
193
+ success: true,
194
+ steps: [],
195
+ summary: "Workflow completed successfully",
196
+ },
197
+ metadata: {},
198
+ });
199
+ return;
200
+ } else {
201
+ this.logger.error(
202
+ "Command failed with exit code:",
203
+ result.exitCode,
204
+ );
205
+ this.send({
206
+ message_type: "result_message",
207
+ structured_output: {
208
+ success: false,
209
+ steps: [],
210
+ summary: "Workflow completed with failure",
211
+ },
212
+ metadata: {},
213
+ });
214
+ return;
215
+ }
216
+ } catch (error) {
217
+ this.logger.error("Error executing command:", error);
218
+ this.send({
219
+ message_type: "error_message",
220
+ error: String(error),
221
+ metadata: {},
222
+ });
223
+ this.send({
224
+ message_type: "result_message",
225
+ structured_output: {
226
+ success: false,
227
+ steps: [],
228
+ summary: "Workflow completed with failure",
229
+ },
230
+ metadata: {},
231
+ });
232
+ throw error;
233
+ }
234
+ } catch (error) {
235
+ this.logger.error("Error executing command:", error);
236
+ throw error;
237
+ }
238
+ }
239
+ stderrStream.close();
240
+ stdoutStream.close();
241
+ }
242
+
243
+ const sender: MessageSender = {
244
+ sendAssistantMessage: (textBlocks: string[]) => {
245
+ this.send({
246
+ message_type: "assistant_message",
247
+ text_blocks: textBlocks,
248
+ });
249
+ },
250
+ sendErrorMessage: (
251
+ error: string,
252
+ metadata?: Record<string, unknown>,
253
+ ) => {
254
+ this.send({
255
+ message_type: "error_message",
256
+ error,
257
+ metadata: metadata ?? {},
258
+ });
259
+ },
260
+ };
261
+
262
+ const agentResult = await this.config.handler.run(
263
+ {
264
+ systemPrompt: message.system_prompt,
265
+ userPrompt: message.user_prompt,
266
+ outputFormat,
267
+ model,
268
+ secrets: message.secrets_to_redact,
269
+ env: message.agent_env ?? {},
270
+ signal: this.abortController.signal,
271
+ logger: this.logger,
272
+ },
273
+ sender,
274
+ );
275
+
276
+ const resultMessage: ResultMessage = {
277
+ message_type: "result_message",
278
+ metadata: agentResult.metadata ?? {},
279
+ structured_output: agentResult.structuredOutput,
280
+ };
281
+ this.logger.log("Sending result message:", JSON.stringify(resultMessage));
282
+ this.send(resultMessage);
283
+ } catch (error) {
284
+ if (this.abortController.signal.aborted) {
285
+ this.ws.close();
286
+ this.logger.log("Agent execution aborted.");
287
+ return;
288
+ }
289
+ this.logger.error("Error in agent execution:", error);
290
+ this.send({
291
+ message_type: "error_message",
292
+ error: String(error),
293
+ metadata: {},
294
+ });
295
+ }
296
+ }
297
+
298
+ private async finalize(): Promise<void> {
299
+ await this.artifactManager?.stopWatching();
300
+
301
+ if (!this.abortController.signal.aborted) {
302
+ await this.artifactManager?.waitForPendingRequests(
303
+ this.config.artifactWaitMs ?? 60_000,
304
+ );
305
+ }
306
+
307
+ await this.config.uploadTracker.waitForAll(
308
+ this.config.uploadTimeoutMs ?? 30_000,
309
+ );
310
+ this.logger.log("All artifacts uploaded.");
311
+ this.ws.close();
312
+ }
313
+
314
+ private send(data: OutgoingMessage): void {
315
+ if (this.ws.readyState === WebSocket.OPEN) {
316
+ this.ws.send(JSON.stringify(data));
317
+ }
318
+ }
319
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,28 @@
1
+ import fs from "fs";
2
+ import { defaultLogger, type Logger } from "./logger.js";
3
+
4
+ export async function uploadFile(
5
+ filePath: string,
6
+ presignedUrl: string,
7
+ contentType: string,
8
+ logger: Logger = defaultLogger,
9
+ ): Promise<boolean> {
10
+ try {
11
+ const content = fs.readFileSync(filePath);
12
+
13
+ const response = await fetch(presignedUrl, {
14
+ method: "PUT",
15
+ headers: {
16
+ "Content-Type": contentType,
17
+ },
18
+ body: content,
19
+ });
20
+
21
+ logger.log(`File ${filePath} uploaded with status: ${response.status}`);
22
+
23
+ return response.status === 200;
24
+ } catch (error) {
25
+ logger.error("Upload error:", error);
26
+ return false;
27
+ }
28
+ }
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ interface Command {
2
+ command: string;
3
+ cwd?: string;
4
+ env?: Record<string, string>;
5
+ }
6
+
7
+ interface RuntimeEnvironmentDownloadable {
8
+ download_url: string;
9
+ working_dir: string;
10
+ }
11
+
12
+ interface InvocationMessage {
13
+ message_type: "invocation_message";
14
+ source_id: string;
15
+ system_prompt: string;
16
+ user_prompt: string;
17
+ secrets_to_redact: string[];
18
+ agent_env?: Record<string, string>;
19
+ output_format_json_schema_str: string;
20
+ preferred_model?: string;
21
+ artifacts_dir?: string;
22
+ pre_agent_invocation_commands?: Command[];
23
+ post_agent_invocation_commands?: Command[];
24
+ runtime_environment_downloadables?: RuntimeEnvironmentDownloadable[];
25
+ }
26
+
27
+ interface CancelMessage {
28
+ message_type: "cancel_message";
29
+ }
30
+
31
+ interface ResultMessage {
32
+ message_type: "result_message";
33
+ metadata?: Record<string, unknown>;
34
+ structured_output: Record<string, unknown>;
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ interface AssistantMessage {
39
+ message_type: "assistant_message";
40
+ text_blocks: string[];
41
+ }
42
+
43
+ interface ArtifactUploadRequestMessage {
44
+ message_type: "artifact_upload_request_message";
45
+ artifact_type:
46
+ | "screenshot"
47
+ | "video"
48
+ | "tool_calls"
49
+ | "javascript"
50
+ | "python"
51
+ | "shellscript"
52
+ | "other";
53
+ filename: string;
54
+ filepath: string;
55
+ }
56
+
57
+ interface ArtifactUploadResponseMessage {
58
+ message_type: "artifact_upload_response_message";
59
+ filename: string;
60
+ filepath: string;
61
+ presigned_url: string;
62
+ content_type: string;
63
+ }
64
+
65
+ interface ErrorMessage {
66
+ message_type: "error_message";
67
+ error: string;
68
+ metadata: Record<string, unknown>;
69
+ }
70
+
71
+ interface StreamEndMessage {
72
+ message_type: "stream_end";
73
+ content: string;
74
+ }
75
+
76
+ type OutgoingMessage =
77
+ | ResultMessage
78
+ | AssistantMessage
79
+ | ArtifactUploadRequestMessage
80
+ | ErrorMessage
81
+ | StreamEndMessage;
82
+
83
+ type IncomingMessage =
84
+ | InvocationMessage
85
+ | ArtifactUploadResponseMessage
86
+ | CancelMessage;
87
+
88
+ export type {
89
+ IncomingMessage,
90
+ OutgoingMessage,
91
+ InvocationMessage,
92
+ CancelMessage,
93
+ ResultMessage,
94
+ AssistantMessage,
95
+ ArtifactUploadRequestMessage,
96
+ ArtifactUploadResponseMessage,
97
+ ErrorMessage,
98
+ StreamEndMessage,
99
+ Command,
100
+ RuntimeEnvironmentDownloadable,
101
+ };
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { UploadTracker } from "./upload-tracker.js";
3
+
4
+ describe("UploadTracker", () => {
5
+ let tracker: UploadTracker;
6
+
7
+ beforeEach(() => {
8
+ tracker = new UploadTracker();
9
+ });
10
+
11
+ it("starts with size 0", () => {
12
+ expect(tracker.size).toBe(0);
13
+ });
14
+
15
+ it("tracks a pending upload", () => {
16
+ tracker.track(new Promise(() => {}));
17
+ expect(tracker.size).toBe(1);
18
+ });
19
+
20
+ it("tracks multiple pending uploads", () => {
21
+ tracker.track(new Promise(() => {}));
22
+ tracker.track(new Promise(() => {}));
23
+ tracker.track(new Promise(() => {}));
24
+ expect(tracker.size).toBe(3);
25
+ });
26
+
27
+ it("removes upload after it resolves", async () => {
28
+ let resolve!: () => void;
29
+ const promise = new Promise<void>((r) => {
30
+ resolve = r;
31
+ });
32
+ tracker.track(promise);
33
+ expect(tracker.size).toBe(1);
34
+
35
+ resolve();
36
+ await promise;
37
+ await flushMicrotasks();
38
+ expect(tracker.size).toBe(0);
39
+ });
40
+
41
+ it("removes upload after it rejects", async () => {
42
+ let reject!: (e: Error) => void;
43
+ const promise = new Promise<void>((_, r) => {
44
+ reject = r;
45
+ });
46
+ vi.spyOn(console, "error").mockImplementation(() => {});
47
+
48
+ tracker.track(promise);
49
+ expect(tracker.size).toBe(1);
50
+
51
+ reject(new Error("fail"));
52
+ await flushMicrotasks();
53
+ expect(tracker.size).toBe(0);
54
+ });
55
+
56
+ it("logs error when a tracked upload rejects", async () => {
57
+ const consoleSpy = vi
58
+ .spyOn(console, "error")
59
+ .mockImplementation(() => {});
60
+
61
+ let reject!: (e: Error) => void;
62
+ const promise = new Promise<void>((_, r) => {
63
+ reject = r;
64
+ });
65
+ tracker.track(promise);
66
+
67
+ const error = new Error("upload failed");
68
+ reject(error);
69
+ await flushMicrotasks();
70
+
71
+ expect(consoleSpy).toHaveBeenCalledWith(
72
+ "Error uploading artifact:",
73
+ error,
74
+ );
75
+ });
76
+
77
+ describe("waitForAll", () => {
78
+ it("resolves immediately when no pending uploads", async () => {
79
+ await tracker.waitForAll(1000);
80
+ });
81
+
82
+ it("resolves when all uploads complete", async () => {
83
+ let resolve1!: () => void;
84
+ let resolve2!: () => void;
85
+ tracker.track(new Promise<void>((r) => { resolve1 = r; }));
86
+ tracker.track(new Promise<void>((r) => { resolve2 = r; }));
87
+
88
+ let waited = false;
89
+ const waitPromise = tracker.waitForAll(5000).then(() => {
90
+ waited = true;
91
+ });
92
+
93
+ resolve1();
94
+ resolve2();
95
+ await waitPromise;
96
+ expect(waited).toBe(true);
97
+ });
98
+
99
+ it("times out if uploads do not complete", async () => {
100
+ tracker.track(new Promise(() => {}));
101
+
102
+ const start = Date.now();
103
+ await tracker.waitForAll(50);
104
+ expect(Date.now() - start).toBeGreaterThanOrEqual(40);
105
+ expect(tracker.size).toBe(1);
106
+ });
107
+ });
108
+ });
109
+
110
+ async function flushMicrotasks() {
111
+ await new Promise((r) => setTimeout(r, 0));
112
+ }
@@ -0,0 +1,30 @@
1
+ import { defaultLogger, type Logger } from "./logger.js";
2
+
3
+ export class UploadTracker {
4
+ private readonly pending = new Set<Promise<any>>();
5
+ private logger: Logger = defaultLogger;
6
+
7
+ setLogger(logger: Logger): void {
8
+ this.logger = logger;
9
+ }
10
+
11
+ track(promise: Promise<any>): void {
12
+ this.pending.add(promise);
13
+ promise
14
+ .catch((error) => this.logger.error("Error uploading artifact:", error))
15
+ .finally(() => this.pending.delete(promise));
16
+ }
17
+
18
+ async waitForAll(timeoutMs: number): Promise<void> {
19
+ if (this.pending.size === 0) return;
20
+ this.logger.log(`Waiting for ${this.pending.size} uploads...`);
21
+ await Promise.race([
22
+ Promise.allSettled(this.pending),
23
+ new Promise<void>((r) => setTimeout(r, timeoutMs)),
24
+ ]);
25
+ }
26
+
27
+ get size(): number {
28
+ return this.pending.size;
29
+ }
30
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { redactSecrets } from "./utils.js";
3
+
4
+ describe("redactSecrets", () => {
5
+ describe("strings", () => {
6
+ it("replaces a secret within a string", () => {
7
+ expect(redactSecrets("token is abc123", ["abc123"])).toBe(
8
+ "token is [REDACTED]",
9
+ );
10
+ });
11
+
12
+ it("replaces multiple occurrences of the same secret", () => {
13
+ expect(redactSecrets("abc123 and abc123", ["abc123"])).toBe(
14
+ "[REDACTED] and [REDACTED]",
15
+ );
16
+ });
17
+
18
+ it("replaces multiple different secrets", () => {
19
+ expect(redactSecrets("key=SECRET1 pass=SECRET2", ["SECRET1", "SECRET2"])).toBe(
20
+ "key=[REDACTED] pass=[REDACTED]",
21
+ );
22
+ });
23
+
24
+ it("returns string unchanged when no secrets match", () => {
25
+ expect(redactSecrets("nothing here", ["xyz"])).toBe("nothing here");
26
+ });
27
+
28
+ it("skips empty-string secrets", () => {
29
+ expect(redactSecrets("hello", [""])).toBe("hello");
30
+ });
31
+ });
32
+
33
+ describe("arrays", () => {
34
+ it("redacts secrets inside array elements", () => {
35
+ expect(redactSecrets(["key=SECRET", "ok"], ["SECRET"])).toEqual([
36
+ "key=[REDACTED]",
37
+ "ok",
38
+ ]);
39
+ });
40
+
41
+ it("handles nested arrays", () => {
42
+ expect(redactSecrets([["SECRET"]], ["SECRET"])).toEqual([
43
+ ["[REDACTED]"],
44
+ ]);
45
+ });
46
+ });
47
+
48
+ describe("objects", () => {
49
+ it("redacts secrets in object values", () => {
50
+ expect(
51
+ redactSecrets({ token: "my-SECRET-value", count: 5 }, ["SECRET"]),
52
+ ).toEqual({ token: "my-[REDACTED]-value", count: 5 });
53
+ });
54
+
55
+ it("redacts secrets in deeply nested objects", () => {
56
+ const input = {
57
+ level1: {
58
+ level2: {
59
+ value: "contains SECRET here",
60
+ },
61
+ },
62
+ };
63
+ expect(redactSecrets(input, ["SECRET"])).toEqual({
64
+ level1: {
65
+ level2: {
66
+ value: "contains [REDACTED] here",
67
+ },
68
+ },
69
+ });
70
+ });
71
+
72
+ it("handles mixed objects with arrays", () => {
73
+ const input = {
74
+ args: ["--token=SECRET", "--verbose"],
75
+ env: { API_KEY: "SECRET" },
76
+ };
77
+ expect(redactSecrets(input, ["SECRET"])).toEqual({
78
+ args: ["--token=[REDACTED]", "--verbose"],
79
+ env: { API_KEY: "[REDACTED]" },
80
+ });
81
+ });
82
+ });
83
+
84
+ describe("non-string primitives", () => {
85
+ it("returns numbers unchanged", () => {
86
+ expect(redactSecrets(42, ["42"])).toBe(42);
87
+ });
88
+
89
+ it("returns booleans unchanged", () => {
90
+ expect(redactSecrets(true, ["true"])).toBe(true);
91
+ });
92
+
93
+ it("returns null unchanged", () => {
94
+ expect(redactSecrets(null, ["null"])).toBeNull();
95
+ });
96
+
97
+ it("returns undefined unchanged", () => {
98
+ expect(redactSecrets(undefined, ["undefined"])).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ describe("edge cases", () => {
103
+ it("returns value unchanged when secrets list is empty", () => {
104
+ const input = { key: "value" };
105
+ expect(redactSecrets(input, [])).toBe(input);
106
+ });
107
+
108
+ it("handles overlapping secrets (longer secret first)", () => {
109
+ expect(
110
+ redactSecrets("my-secret-key", ["my-secret-key", "secret"]),
111
+ ).toBe("[REDACTED]");
112
+ });
113
+
114
+ it("handles overlapping secrets (shorter secret first)", () => {
115
+ expect(
116
+ redactSecrets("my-secret-key", ["secret", "my-secret-key"]),
117
+ ).toBe("my-[REDACTED]-key");
118
+ });
119
+ });
120
+ });