@pinpatch/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.cjs +1365 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1240 -0
- package/dist/index.d.ts +1240 -0
- package/dist/index.js +1289 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1289 @@
|
|
|
1
|
+
// src/contracts/artifacts.ts
|
|
2
|
+
import { z as z2 } from "zod";
|
|
3
|
+
|
|
4
|
+
// src/contracts/provider.ts
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
var ProviderNameSchema = z.enum(["codex", "claude", "cursor"]);
|
|
7
|
+
var ProviderProgressStatusSchema = z.enum([
|
|
8
|
+
"queued",
|
|
9
|
+
"running",
|
|
10
|
+
"completed",
|
|
11
|
+
"error",
|
|
12
|
+
"cancelled",
|
|
13
|
+
"timeout"
|
|
14
|
+
]);
|
|
15
|
+
var ProviderTerminalStatusSchema = z.enum(["completed", "error", "cancelled", "timeout"]);
|
|
16
|
+
var ProviderProgressSchema = z.object({
|
|
17
|
+
taskId: z.string().min(1),
|
|
18
|
+
sessionId: z.string().min(1),
|
|
19
|
+
status: ProviderProgressStatusSchema,
|
|
20
|
+
message: z.string().min(1),
|
|
21
|
+
percent: z.number().min(0).max(100).optional(),
|
|
22
|
+
timestamp: z.string().datetime()
|
|
23
|
+
});
|
|
24
|
+
var ProviderResultSchema = z.object({
|
|
25
|
+
taskId: z.string().min(1),
|
|
26
|
+
sessionId: z.string().min(1),
|
|
27
|
+
status: ProviderTerminalStatusSchema,
|
|
28
|
+
summary: z.string().min(1),
|
|
29
|
+
changedFiles: z.array(z.string()),
|
|
30
|
+
errorCode: z.string().optional(),
|
|
31
|
+
errorMessage: z.string().optional()
|
|
32
|
+
});
|
|
33
|
+
var ProviderErrorCodes = {
|
|
34
|
+
ProviderUnavailable: "PROVIDER_UNAVAILABLE",
|
|
35
|
+
ProviderNotEnabled: "PROVIDER_NOT_ENABLED",
|
|
36
|
+
ProviderTimeout: "PROVIDER_TIMEOUT",
|
|
37
|
+
ProcessFailed: "PROVIDER_PROCESS_FAILED",
|
|
38
|
+
ValidationFailed: "PROVIDER_VALIDATION_FAILED",
|
|
39
|
+
Unknown: "UNKNOWN"
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/contracts/artifacts.ts
|
|
43
|
+
var PinStateSchema = z2.enum([
|
|
44
|
+
"idle",
|
|
45
|
+
"queued",
|
|
46
|
+
"running",
|
|
47
|
+
"completed",
|
|
48
|
+
"error",
|
|
49
|
+
"cancelled",
|
|
50
|
+
"timeout"
|
|
51
|
+
]);
|
|
52
|
+
var ViewportSchema = z2.object({
|
|
53
|
+
width: z2.number().int().positive(),
|
|
54
|
+
height: z2.number().int().positive()
|
|
55
|
+
});
|
|
56
|
+
var BoundingBoxSchema = z2.object({
|
|
57
|
+
x: z2.number(),
|
|
58
|
+
y: z2.number(),
|
|
59
|
+
width: z2.number(),
|
|
60
|
+
height: z2.number()
|
|
61
|
+
});
|
|
62
|
+
var ElementDescriptorSchema = z2.object({
|
|
63
|
+
tag: z2.string().min(1),
|
|
64
|
+
role: z2.string().nullable().optional(),
|
|
65
|
+
text: z2.string().nullable().optional(),
|
|
66
|
+
attributes: z2.record(z2.union([z2.string(), z2.null()])),
|
|
67
|
+
boundingBox: BoundingBoxSchema
|
|
68
|
+
});
|
|
69
|
+
var UiChangePacketSchema = z2.object({
|
|
70
|
+
id: z2.string().min(1),
|
|
71
|
+
timestamp: z2.string().datetime(),
|
|
72
|
+
url: z2.string().min(1),
|
|
73
|
+
viewport: ViewportSchema,
|
|
74
|
+
element: ElementDescriptorSchema,
|
|
75
|
+
nearbyText: z2.array(z2.string()),
|
|
76
|
+
domSnippet: z2.string(),
|
|
77
|
+
computedStyleSummary: z2.record(z2.string()),
|
|
78
|
+
screenshotPath: z2.string().min(1),
|
|
79
|
+
userRequest: z2.string().min(1)
|
|
80
|
+
});
|
|
81
|
+
var TaskStatusSchema = z2.enum([
|
|
82
|
+
"created",
|
|
83
|
+
"queued",
|
|
84
|
+
"running",
|
|
85
|
+
"completed",
|
|
86
|
+
"error",
|
|
87
|
+
"cancelled",
|
|
88
|
+
"timeout"
|
|
89
|
+
]);
|
|
90
|
+
var TaskPinSchema = z2.object({
|
|
91
|
+
x: z2.number(),
|
|
92
|
+
y: z2.number(),
|
|
93
|
+
body: z2.string().min(1)
|
|
94
|
+
});
|
|
95
|
+
var TaskRecordSchema = z2.object({
|
|
96
|
+
taskId: z2.string().min(1),
|
|
97
|
+
createdAt: z2.string().datetime(),
|
|
98
|
+
updatedAt: z2.string().datetime(),
|
|
99
|
+
status: TaskStatusSchema,
|
|
100
|
+
url: z2.string().min(1),
|
|
101
|
+
viewport: ViewportSchema,
|
|
102
|
+
pin: TaskPinSchema,
|
|
103
|
+
uiChangePacket: UiChangePacketSchema,
|
|
104
|
+
screenshotPath: z2.string().min(1),
|
|
105
|
+
provider: ProviderNameSchema.optional(),
|
|
106
|
+
model: z2.string().optional(),
|
|
107
|
+
latestSessionId: z2.string().optional(),
|
|
108
|
+
sessions: z2.array(z2.string()),
|
|
109
|
+
summary: z2.string().optional(),
|
|
110
|
+
changedFiles: z2.array(z2.string()).default([]),
|
|
111
|
+
errorCode: z2.string().optional(),
|
|
112
|
+
errorMessage: z2.string().optional()
|
|
113
|
+
});
|
|
114
|
+
var SessionEventSchema = z2.object({
|
|
115
|
+
status: ProviderProgressStatusSchema,
|
|
116
|
+
message: z2.string().min(1),
|
|
117
|
+
percent: z2.number().min(0).max(100).optional(),
|
|
118
|
+
timestamp: z2.string().datetime()
|
|
119
|
+
});
|
|
120
|
+
var SessionRecordSchema = z2.object({
|
|
121
|
+
sessionId: z2.string().min(1),
|
|
122
|
+
taskId: z2.string().min(1),
|
|
123
|
+
provider: ProviderNameSchema,
|
|
124
|
+
model: z2.string().min(1),
|
|
125
|
+
status: ProviderProgressStatusSchema,
|
|
126
|
+
dryRun: z2.boolean(),
|
|
127
|
+
startedAt: z2.string().datetime(),
|
|
128
|
+
updatedAt: z2.string().datetime(),
|
|
129
|
+
endedAt: z2.string().datetime().optional(),
|
|
130
|
+
events: z2.array(SessionEventSchema),
|
|
131
|
+
summary: z2.string().optional(),
|
|
132
|
+
changedFiles: z2.array(z2.string()).default([]),
|
|
133
|
+
errorCode: z2.string().optional(),
|
|
134
|
+
errorMessage: z2.string().optional()
|
|
135
|
+
});
|
|
136
|
+
var RuntimeLogLevelSchema = z2.enum(["debug", "info", "warn", "error"]);
|
|
137
|
+
var RuntimeLogEventSchema = z2.object({
|
|
138
|
+
timestamp: z2.string().datetime(),
|
|
139
|
+
level: RuntimeLogLevelSchema,
|
|
140
|
+
component: z2.string().min(1),
|
|
141
|
+
taskId: z2.string().optional(),
|
|
142
|
+
sessionId: z2.string().optional(),
|
|
143
|
+
event: z2.string().min(1),
|
|
144
|
+
message: z2.string().min(1),
|
|
145
|
+
meta: z2.record(z2.unknown()).optional()
|
|
146
|
+
});
|
|
147
|
+
var PinpatchConfigSchema = z2.object({
|
|
148
|
+
provider: ProviderNameSchema.default("codex"),
|
|
149
|
+
model: z2.string().default("gpt-5.3-codex-spark"),
|
|
150
|
+
target: z2.number().int().positive().default(3e3),
|
|
151
|
+
debug: z2.boolean().default(false),
|
|
152
|
+
bridgePort: z2.number().int().positive().default(7331),
|
|
153
|
+
proxyPort: z2.number().int().positive().default(3030)
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// src/contracts/bridge.ts
|
|
157
|
+
import { z as z3 } from "zod";
|
|
158
|
+
var CreateTaskRequestSchema = z3.object({
|
|
159
|
+
sessionId: z3.string().min(1),
|
|
160
|
+
url: z3.string().min(1),
|
|
161
|
+
viewport: ViewportSchema,
|
|
162
|
+
pin: z3.object({
|
|
163
|
+
x: z3.number(),
|
|
164
|
+
y: z3.number(),
|
|
165
|
+
body: z3.string().min(1)
|
|
166
|
+
}),
|
|
167
|
+
uiChangePacket: UiChangePacketSchema,
|
|
168
|
+
screenshotPath: z3.string().min(1),
|
|
169
|
+
screenshotDataUrl: z3.string().startsWith("data:image/").optional(),
|
|
170
|
+
clientTaskId: z3.string().min(1).optional()
|
|
171
|
+
});
|
|
172
|
+
var CreateTaskResponseSchema = z3.object({
|
|
173
|
+
taskId: z3.string(),
|
|
174
|
+
sessionId: z3.string(),
|
|
175
|
+
status: z3.literal("created"),
|
|
176
|
+
taskPath: z3.string(),
|
|
177
|
+
eventsUrl: z3.string()
|
|
178
|
+
});
|
|
179
|
+
var SubmitTaskRequestSchema = z3.object({
|
|
180
|
+
sessionId: z3.string().min(1),
|
|
181
|
+
provider: ProviderNameSchema,
|
|
182
|
+
model: z3.string().min(1),
|
|
183
|
+
dryRun: z3.boolean().default(false),
|
|
184
|
+
debug: z3.boolean().default(false),
|
|
185
|
+
followUpBody: z3.string().trim().min(1).optional()
|
|
186
|
+
});
|
|
187
|
+
var SubmitTaskResponseSchema = z3.object({
|
|
188
|
+
taskId: z3.string().min(1),
|
|
189
|
+
sessionId: z3.string().min(1),
|
|
190
|
+
status: z3.literal("queued"),
|
|
191
|
+
acceptedAt: z3.string().datetime(),
|
|
192
|
+
eventsUrl: z3.string().min(1)
|
|
193
|
+
});
|
|
194
|
+
var SseProgressEventSchema = z3.object({
|
|
195
|
+
type: z3.literal("progress"),
|
|
196
|
+
taskId: z3.string().min(1),
|
|
197
|
+
sessionId: z3.string().min(1),
|
|
198
|
+
status: z3.enum([
|
|
199
|
+
"queued",
|
|
200
|
+
"running",
|
|
201
|
+
"completed",
|
|
202
|
+
"error",
|
|
203
|
+
"cancelled",
|
|
204
|
+
"timeout"
|
|
205
|
+
]),
|
|
206
|
+
message: z3.string().min(1),
|
|
207
|
+
percent: z3.number().min(0).max(100).optional(),
|
|
208
|
+
timestamp: z3.string().datetime()
|
|
209
|
+
});
|
|
210
|
+
var SseTerminalEventSchema = z3.object({
|
|
211
|
+
type: z3.literal("terminal"),
|
|
212
|
+
taskId: z3.string().min(1),
|
|
213
|
+
sessionId: z3.string().min(1),
|
|
214
|
+
status: z3.enum(["completed", "error", "cancelled", "timeout"]),
|
|
215
|
+
summary: z3.string().min(1),
|
|
216
|
+
changedFiles: z3.array(z3.string()),
|
|
217
|
+
errorCode: z3.string().optional(),
|
|
218
|
+
errorMessage: z3.string().optional(),
|
|
219
|
+
timestamp: z3.string().datetime()
|
|
220
|
+
});
|
|
221
|
+
var SseHeartbeatEventSchema = z3.object({
|
|
222
|
+
type: z3.literal("heartbeat"),
|
|
223
|
+
timestamp: z3.string().datetime()
|
|
224
|
+
});
|
|
225
|
+
var SseEventSchema = z3.union([
|
|
226
|
+
SseProgressEventSchema,
|
|
227
|
+
SseTerminalEventSchema,
|
|
228
|
+
SseHeartbeatEventSchema
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
// src/config.ts
|
|
232
|
+
import path from "path";
|
|
233
|
+
import { promises as fs } from "fs";
|
|
234
|
+
var DEFAULT_CONFIG = {
|
|
235
|
+
provider: "codex",
|
|
236
|
+
model: "gpt-5.3-codex-spark",
|
|
237
|
+
target: 3e3,
|
|
238
|
+
debug: false,
|
|
239
|
+
bridgePort: 7331,
|
|
240
|
+
proxyPort: 3030
|
|
241
|
+
};
|
|
242
|
+
var DEFAULT_CLAUDE_MODEL = "sonnet";
|
|
243
|
+
var resolveConfigPath = (cwd) => path.join(cwd, ".pinpatch", "config.json");
|
|
244
|
+
var omitUndefined = (value) => {
|
|
245
|
+
return Object.fromEntries(Object.entries(value).filter(([, fieldValue]) => fieldValue !== void 0));
|
|
246
|
+
};
|
|
247
|
+
var readConfigFile = async (cwd) => {
|
|
248
|
+
const configPath = resolveConfigPath(cwd);
|
|
249
|
+
try {
|
|
250
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
251
|
+
const parsed = JSON.parse(raw);
|
|
252
|
+
return PinpatchConfigSchema.partial().parse(parsed);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error.code === "ENOENT") {
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
return {};
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
var resolveConfig = async (cwd, overrides = {}) => {
|
|
261
|
+
const fileConfig = await readConfigFile(cwd);
|
|
262
|
+
const overrideConfig = omitUndefined(overrides);
|
|
263
|
+
const hasCliModelOverride = Object.prototype.hasOwnProperty.call(overrideConfig, "model");
|
|
264
|
+
const hasFileModel = Object.prototype.hasOwnProperty.call(fileConfig, "model");
|
|
265
|
+
const hasFileProvider = Object.prototype.hasOwnProperty.call(fileConfig, "provider");
|
|
266
|
+
const isBaselineFileModel = hasFileModel && hasFileProvider && fileConfig.model === DEFAULT_CONFIG.model && fileConfig.provider === DEFAULT_CONFIG.provider;
|
|
267
|
+
const merged = {
|
|
268
|
+
...DEFAULT_CONFIG,
|
|
269
|
+
...fileConfig,
|
|
270
|
+
...overrideConfig
|
|
271
|
+
};
|
|
272
|
+
const shouldUseClaudeDefault = merged.provider === "claude" && merged.model === DEFAULT_CONFIG.model && !hasCliModelOverride && (!hasFileModel || isBaselineFileModel);
|
|
273
|
+
if (shouldUseClaudeDefault) {
|
|
274
|
+
merged.model = DEFAULT_CLAUDE_MODEL;
|
|
275
|
+
}
|
|
276
|
+
return PinpatchConfigSchema.parse(merged);
|
|
277
|
+
};
|
|
278
|
+
var ensureConfigFile = async (cwd) => {
|
|
279
|
+
const configPath = resolveConfigPath(cwd);
|
|
280
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
281
|
+
try {
|
|
282
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
283
|
+
return PinpatchConfigSchema.parse(JSON.parse(raw));
|
|
284
|
+
} catch {
|
|
285
|
+
await fs.writeFile(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}
|
|
286
|
+
`, "utf8");
|
|
287
|
+
return DEFAULT_CONFIG;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
var getConfigPath = resolveConfigPath;
|
|
291
|
+
|
|
292
|
+
// src/storage/artifact-store.ts
|
|
293
|
+
import path3 from "path";
|
|
294
|
+
import { promises as fs3 } from "fs";
|
|
295
|
+
|
|
296
|
+
// src/utils/fs.ts
|
|
297
|
+
import { promises as fs2 } from "fs";
|
|
298
|
+
import path2 from "path";
|
|
299
|
+
import crypto from "crypto";
|
|
300
|
+
var ensureDir = async (dirPath) => {
|
|
301
|
+
await fs2.mkdir(dirPath, { recursive: true });
|
|
302
|
+
};
|
|
303
|
+
var writeJsonAtomic = async (filePath, payload) => {
|
|
304
|
+
const dir = path2.dirname(filePath);
|
|
305
|
+
await ensureDir(dir);
|
|
306
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
|
307
|
+
await fs2.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}
|
|
308
|
+
`, "utf8");
|
|
309
|
+
await fs2.rename(tempPath, filePath);
|
|
310
|
+
};
|
|
311
|
+
var readJsonIfExists = async (filePath) => {
|
|
312
|
+
try {
|
|
313
|
+
const raw = await fs2.readFile(filePath, "utf8");
|
|
314
|
+
return JSON.parse(raw);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error.code === "ENOENT") {
|
|
317
|
+
return void 0;
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
var listJsonFiles = async (dirPath) => {
|
|
323
|
+
try {
|
|
324
|
+
const entries = await fs2.readdir(dirPath, { withFileTypes: true });
|
|
325
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => path2.join(dirPath, entry.name));
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error.code === "ENOENT") {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// src/storage/artifact-store.ts
|
|
335
|
+
var ArtifactStore = class {
|
|
336
|
+
cwd;
|
|
337
|
+
rootDir;
|
|
338
|
+
tasksDir;
|
|
339
|
+
sessionsDir;
|
|
340
|
+
screenshotsDir;
|
|
341
|
+
runtimeDir;
|
|
342
|
+
logsDir;
|
|
343
|
+
configPath;
|
|
344
|
+
constructor(cwd) {
|
|
345
|
+
this.cwd = cwd;
|
|
346
|
+
this.rootDir = path3.join(cwd, ".pinpatch");
|
|
347
|
+
this.tasksDir = path3.join(this.rootDir, "tasks");
|
|
348
|
+
this.sessionsDir = path3.join(this.rootDir, "sessions");
|
|
349
|
+
this.screenshotsDir = path3.join(this.rootDir, "screenshots");
|
|
350
|
+
this.runtimeDir = path3.join(this.rootDir, "runtime");
|
|
351
|
+
this.logsDir = path3.join(this.runtimeDir, "logs");
|
|
352
|
+
this.configPath = path3.join(this.rootDir, "config.json");
|
|
353
|
+
}
|
|
354
|
+
async ensureStructure() {
|
|
355
|
+
await ensureDir(this.tasksDir);
|
|
356
|
+
await ensureDir(this.sessionsDir);
|
|
357
|
+
await ensureDir(this.screenshotsDir);
|
|
358
|
+
await ensureDir(this.logsDir);
|
|
359
|
+
const existingConfig = await readJsonIfExists(this.configPath);
|
|
360
|
+
if (!existingConfig) {
|
|
361
|
+
await writeJsonAtomic(this.configPath, DEFAULT_CONFIG);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async ensureGitignoreEntry() {
|
|
365
|
+
const gitignorePath = path3.join(this.cwd, ".gitignore");
|
|
366
|
+
try {
|
|
367
|
+
const content = await fs3.readFile(gitignorePath, "utf8");
|
|
368
|
+
if (!content.includes(".pinpatch/")) {
|
|
369
|
+
await fs3.appendFile(gitignorePath, "\n.pinpatch/\n", "utf8");
|
|
370
|
+
}
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error.code === "ENOENT") {
|
|
373
|
+
await fs3.writeFile(gitignorePath, ".pinpatch/\n", "utf8");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
getTaskPath(taskId) {
|
|
380
|
+
return path3.join(this.tasksDir, `${taskId}.json`);
|
|
381
|
+
}
|
|
382
|
+
getSessionPath(sessionId) {
|
|
383
|
+
return path3.join(this.sessionsDir, `${sessionId}.json`);
|
|
384
|
+
}
|
|
385
|
+
getRelativePath(absolutePath) {
|
|
386
|
+
return path3.relative(this.cwd, absolutePath);
|
|
387
|
+
}
|
|
388
|
+
async readConfig() {
|
|
389
|
+
const data = await readJsonIfExists(this.configPath);
|
|
390
|
+
return PinpatchConfigSchema.parse({ ...DEFAULT_CONFIG, ...data });
|
|
391
|
+
}
|
|
392
|
+
async writeConfig(config) {
|
|
393
|
+
const validated = PinpatchConfigSchema.parse(config);
|
|
394
|
+
await writeJsonAtomic(this.configPath, validated);
|
|
395
|
+
}
|
|
396
|
+
async createTask(task) {
|
|
397
|
+
const validated = TaskRecordSchema.parse(task);
|
|
398
|
+
await writeJsonAtomic(this.getTaskPath(validated.taskId), validated);
|
|
399
|
+
return validated;
|
|
400
|
+
}
|
|
401
|
+
async getTask(taskId) {
|
|
402
|
+
const raw = await readJsonIfExists(this.getTaskPath(taskId));
|
|
403
|
+
if (!raw) {
|
|
404
|
+
return void 0;
|
|
405
|
+
}
|
|
406
|
+
return TaskRecordSchema.parse(raw);
|
|
407
|
+
}
|
|
408
|
+
async updateTask(taskId, updater) {
|
|
409
|
+
const current = await this.getTask(taskId);
|
|
410
|
+
if (!current) {
|
|
411
|
+
throw new Error(`Task ${taskId} does not exist`);
|
|
412
|
+
}
|
|
413
|
+
const updated = TaskRecordSchema.parse(updater(current));
|
|
414
|
+
await writeJsonAtomic(this.getTaskPath(taskId), updated);
|
|
415
|
+
return updated;
|
|
416
|
+
}
|
|
417
|
+
async listTasks() {
|
|
418
|
+
const files = await listJsonFiles(this.tasksDir);
|
|
419
|
+
const rows = await Promise.all(
|
|
420
|
+
files.map(async (filePath) => {
|
|
421
|
+
const raw = await readJsonIfExists(filePath);
|
|
422
|
+
return raw ? TaskRecordSchema.parse(raw) : void 0;
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
return rows.filter((row) => row !== void 0);
|
|
426
|
+
}
|
|
427
|
+
async createSession(session) {
|
|
428
|
+
const validated = SessionRecordSchema.parse(session);
|
|
429
|
+
await writeJsonAtomic(this.getSessionPath(validated.sessionId), validated);
|
|
430
|
+
return validated;
|
|
431
|
+
}
|
|
432
|
+
async getSession(sessionId) {
|
|
433
|
+
const raw = await readJsonIfExists(this.getSessionPath(sessionId));
|
|
434
|
+
if (!raw) {
|
|
435
|
+
return void 0;
|
|
436
|
+
}
|
|
437
|
+
return SessionRecordSchema.parse(raw);
|
|
438
|
+
}
|
|
439
|
+
async updateSession(sessionId, updater) {
|
|
440
|
+
const current = await this.getSession(sessionId);
|
|
441
|
+
if (!current) {
|
|
442
|
+
throw new Error(`Session ${sessionId} does not exist`);
|
|
443
|
+
}
|
|
444
|
+
const updated = SessionRecordSchema.parse(updater(current));
|
|
445
|
+
await writeJsonAtomic(this.getSessionPath(sessionId), updated);
|
|
446
|
+
return updated;
|
|
447
|
+
}
|
|
448
|
+
async listSessions() {
|
|
449
|
+
const files = await listJsonFiles(this.sessionsDir);
|
|
450
|
+
const rows = await Promise.all(
|
|
451
|
+
files.map(async (filePath) => {
|
|
452
|
+
const raw = await readJsonIfExists(filePath);
|
|
453
|
+
return raw ? SessionRecordSchema.parse(raw) : void 0;
|
|
454
|
+
})
|
|
455
|
+
);
|
|
456
|
+
return rows.filter((row) => row !== void 0);
|
|
457
|
+
}
|
|
458
|
+
async writeScreenshot(taskId, screenshotDataUrl) {
|
|
459
|
+
const matches = screenshotDataUrl.match(/^data:image\/(png|jpeg|jpg);base64,(?<bytes>.+)$/);
|
|
460
|
+
if (!matches?.groups?.bytes) {
|
|
461
|
+
throw new Error("Invalid screenshot payload");
|
|
462
|
+
}
|
|
463
|
+
const filePath = path3.join(this.screenshotsDir, `${taskId}.png`);
|
|
464
|
+
const buffer = Buffer.from(matches.groups.bytes, "base64");
|
|
465
|
+
await fs3.writeFile(filePath, buffer);
|
|
466
|
+
return this.getRelativePath(filePath);
|
|
467
|
+
}
|
|
468
|
+
async appendLog(logPath, event) {
|
|
469
|
+
const validated = RuntimeLogEventSchema.parse(event);
|
|
470
|
+
await fs3.appendFile(logPath, `${JSON.stringify(validated)}
|
|
471
|
+
`, "utf8");
|
|
472
|
+
}
|
|
473
|
+
async prune(options) {
|
|
474
|
+
const logsOlderThanDays = options?.logsOlderThanDays ?? 14;
|
|
475
|
+
const orphanSessionAgeHours = options?.orphanSessionAgeHours ?? 24;
|
|
476
|
+
const logFiles = await fs3.readdir(this.logsDir).catch(() => []);
|
|
477
|
+
const cutoffLogs = Date.now() - logsOlderThanDays * 24 * 60 * 60 * 1e3;
|
|
478
|
+
let removedLogs = 0;
|
|
479
|
+
for (const file of logFiles) {
|
|
480
|
+
const fullPath = path3.join(this.logsDir, file);
|
|
481
|
+
const stats = await fs3.stat(fullPath).catch(() => void 0);
|
|
482
|
+
if (!stats) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (stats.mtimeMs < cutoffLogs) {
|
|
486
|
+
await fs3.unlink(fullPath);
|
|
487
|
+
removedLogs += 1;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const tasks = await this.listTasks();
|
|
491
|
+
const taskIds = new Set(tasks.map((task) => task.taskId));
|
|
492
|
+
const sessions = await this.listSessions();
|
|
493
|
+
const cutoffSessions = Date.now() - orphanSessionAgeHours * 60 * 60 * 1e3;
|
|
494
|
+
let removedSessions = 0;
|
|
495
|
+
for (const session of sessions) {
|
|
496
|
+
const updatedAtMs = Date.parse(session.updatedAt);
|
|
497
|
+
const isOrphan = !taskIds.has(session.taskId);
|
|
498
|
+
if (isOrphan && updatedAtMs < cutoffSessions) {
|
|
499
|
+
await fs3.unlink(this.getSessionPath(session.sessionId));
|
|
500
|
+
removedSessions += 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
removedLogs,
|
|
505
|
+
removedSessions
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// src/runtime/ids.ts
|
|
511
|
+
import crypto2 from "crypto";
|
|
512
|
+
var nowIso = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
513
|
+
var generateSessionId = () => crypto2.randomUUID();
|
|
514
|
+
var generateTaskId = () => {
|
|
515
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
516
|
+
const suffix = crypto2.randomBytes(3).toString("hex");
|
|
517
|
+
return `${date}-${suffix}`;
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// src/runtime/task-runner.ts
|
|
521
|
+
var toMillis = (timestamp) => {
|
|
522
|
+
const value = Date.parse(timestamp);
|
|
523
|
+
return Number.isNaN(value) ? Date.now() : value;
|
|
524
|
+
};
|
|
525
|
+
var forceMonotonicTimestamp = (nextTs, lastTs) => {
|
|
526
|
+
const parsed = toMillis(nextTs);
|
|
527
|
+
if (parsed > lastTs) {
|
|
528
|
+
return { timestamp: new Date(parsed).toISOString(), millis: parsed };
|
|
529
|
+
}
|
|
530
|
+
const shifted = lastTs + 1;
|
|
531
|
+
return { timestamp: new Date(shifted).toISOString(), millis: shifted };
|
|
532
|
+
};
|
|
533
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
534
|
+
"completed",
|
|
535
|
+
"error",
|
|
536
|
+
"cancelled",
|
|
537
|
+
"timeout"
|
|
538
|
+
]);
|
|
539
|
+
var isTerminalStatus = (status) => TERMINAL_STATUSES.has(status);
|
|
540
|
+
var TaskRunner = class {
|
|
541
|
+
cwd;
|
|
542
|
+
store;
|
|
543
|
+
logger;
|
|
544
|
+
eventBus;
|
|
545
|
+
getProviderAdapter;
|
|
546
|
+
defaultTimeoutMs;
|
|
547
|
+
dryRunTimeoutMs;
|
|
548
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
549
|
+
constructor(options) {
|
|
550
|
+
this.cwd = options.cwd;
|
|
551
|
+
this.store = options.store;
|
|
552
|
+
this.logger = options.logger;
|
|
553
|
+
this.eventBus = options.eventBus;
|
|
554
|
+
this.getProviderAdapter = options.getProviderAdapter;
|
|
555
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 10 * 60 * 1e3;
|
|
556
|
+
this.dryRunTimeoutMs = options.dryRunTimeoutMs ?? 2 * 60 * 1e3;
|
|
557
|
+
}
|
|
558
|
+
key(taskId, sessionId) {
|
|
559
|
+
return `${taskId}:${sessionId}`;
|
|
560
|
+
}
|
|
561
|
+
async cancelTask(taskId, sessionId) {
|
|
562
|
+
const key = this.key(taskId, sessionId);
|
|
563
|
+
const entry = this.inFlight.get(key);
|
|
564
|
+
if (!entry) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
await entry.adapter.cancelTask(taskId, sessionId);
|
|
568
|
+
this.logger.info("Cancel request forwarded to provider", {
|
|
569
|
+
component: "runner",
|
|
570
|
+
taskId,
|
|
571
|
+
sessionId,
|
|
572
|
+
event: "task.cancel"
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async runTask(input) {
|
|
576
|
+
const { taskId, sessionId, provider, model, dryRun, debug } = input;
|
|
577
|
+
const task = await this.store.getTask(taskId);
|
|
578
|
+
if (!task) {
|
|
579
|
+
throw new Error(`Task ${taskId} does not exist`);
|
|
580
|
+
}
|
|
581
|
+
const queuedTimestamp = nowIso();
|
|
582
|
+
await this.store.updateTask(taskId, (current) => ({
|
|
583
|
+
...current,
|
|
584
|
+
status: "queued",
|
|
585
|
+
provider,
|
|
586
|
+
model,
|
|
587
|
+
latestSessionId: sessionId,
|
|
588
|
+
sessions: Array.from(/* @__PURE__ */ new Set([...current.sessions, sessionId])),
|
|
589
|
+
updatedAt: queuedTimestamp
|
|
590
|
+
}));
|
|
591
|
+
await this.store.createSession({
|
|
592
|
+
sessionId,
|
|
593
|
+
taskId,
|
|
594
|
+
provider,
|
|
595
|
+
model,
|
|
596
|
+
status: "queued",
|
|
597
|
+
dryRun,
|
|
598
|
+
startedAt: queuedTimestamp,
|
|
599
|
+
updatedAt: queuedTimestamp,
|
|
600
|
+
events: [
|
|
601
|
+
{
|
|
602
|
+
status: "queued",
|
|
603
|
+
message: "Task queued",
|
|
604
|
+
timestamp: queuedTimestamp
|
|
605
|
+
}
|
|
606
|
+
],
|
|
607
|
+
changedFiles: []
|
|
608
|
+
});
|
|
609
|
+
const adapter = this.getProviderAdapter(provider);
|
|
610
|
+
if (!adapter) {
|
|
611
|
+
const unavailable = {
|
|
612
|
+
taskId,
|
|
613
|
+
sessionId,
|
|
614
|
+
status: "error",
|
|
615
|
+
summary: `Provider ${provider} is not available in this runtime`,
|
|
616
|
+
changedFiles: [],
|
|
617
|
+
errorCode: ProviderErrorCodes.ProviderUnavailable,
|
|
618
|
+
errorMessage: `Provider ${provider} was not registered`
|
|
619
|
+
};
|
|
620
|
+
await this.persistTerminalState(
|
|
621
|
+
task,
|
|
622
|
+
unavailable,
|
|
623
|
+
provider,
|
|
624
|
+
model,
|
|
625
|
+
dryRun
|
|
626
|
+
);
|
|
627
|
+
return unavailable;
|
|
628
|
+
}
|
|
629
|
+
const queuedEvent = {
|
|
630
|
+
type: "progress",
|
|
631
|
+
taskId,
|
|
632
|
+
sessionId,
|
|
633
|
+
status: "queued",
|
|
634
|
+
message: "Task queued",
|
|
635
|
+
timestamp: queuedTimestamp
|
|
636
|
+
};
|
|
637
|
+
this.eventBus.publish(taskId, sessionId, queuedEvent);
|
|
638
|
+
const key = this.key(taskId, sessionId);
|
|
639
|
+
this.inFlight.set(key, {
|
|
640
|
+
adapter,
|
|
641
|
+
startedAt: Date.now()
|
|
642
|
+
});
|
|
643
|
+
let lastProgressTime = toMillis(queuedTimestamp);
|
|
644
|
+
const timeoutMs = dryRun ? this.dryRunTimeoutMs : this.defaultTimeoutMs;
|
|
645
|
+
let terminalCommitted = false;
|
|
646
|
+
const handleProgress = async (event) => {
|
|
647
|
+
if (terminalCommitted) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const monotonic = forceMonotonicTimestamp(
|
|
651
|
+
event.timestamp,
|
|
652
|
+
lastProgressTime
|
|
653
|
+
);
|
|
654
|
+
lastProgressTime = monotonic.millis;
|
|
655
|
+
const normalizedEvent = {
|
|
656
|
+
...event,
|
|
657
|
+
timestamp: monotonic.timestamp
|
|
658
|
+
};
|
|
659
|
+
await this.store.updateSession(sessionId, (session) => {
|
|
660
|
+
if (isTerminalStatus(session.status)) {
|
|
661
|
+
return session;
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
...session,
|
|
665
|
+
status: normalizedEvent.status,
|
|
666
|
+
updatedAt: normalizedEvent.timestamp,
|
|
667
|
+
events: [
|
|
668
|
+
...session.events,
|
|
669
|
+
{
|
|
670
|
+
status: normalizedEvent.status,
|
|
671
|
+
message: normalizedEvent.message,
|
|
672
|
+
percent: normalizedEvent.percent,
|
|
673
|
+
timestamp: normalizedEvent.timestamp
|
|
674
|
+
}
|
|
675
|
+
]
|
|
676
|
+
};
|
|
677
|
+
});
|
|
678
|
+
if (terminalCommitted) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (normalizedEvent.status === "running" || normalizedEvent.status === "queued") {
|
|
682
|
+
await this.store.updateTask(taskId, (current) => {
|
|
683
|
+
if (isTerminalStatus(current.status)) {
|
|
684
|
+
return current;
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
...current,
|
|
688
|
+
status: normalizedEvent.status,
|
|
689
|
+
updatedAt: normalizedEvent.timestamp
|
|
690
|
+
};
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (terminalCommitted) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
this.eventBus.publish(taskId, sessionId, {
|
|
697
|
+
type: "progress",
|
|
698
|
+
taskId,
|
|
699
|
+
sessionId,
|
|
700
|
+
status: normalizedEvent.status,
|
|
701
|
+
message: normalizedEvent.message,
|
|
702
|
+
percent: normalizedEvent.percent,
|
|
703
|
+
timestamp: normalizedEvent.timestamp
|
|
704
|
+
});
|
|
705
|
+
this.logger.debug(normalizedEvent.message, {
|
|
706
|
+
component: "runner",
|
|
707
|
+
taskId,
|
|
708
|
+
sessionId,
|
|
709
|
+
event: "task.progress",
|
|
710
|
+
meta: {
|
|
711
|
+
status: normalizedEvent.status,
|
|
712
|
+
percent: normalizedEvent.percent
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
};
|
|
716
|
+
try {
|
|
717
|
+
const providerTaskPromise = adapter.submitTask(
|
|
718
|
+
{
|
|
719
|
+
taskId,
|
|
720
|
+
sessionId,
|
|
721
|
+
task,
|
|
722
|
+
prompt: this.buildPrompt(task),
|
|
723
|
+
model,
|
|
724
|
+
dryRun,
|
|
725
|
+
debug,
|
|
726
|
+
cwd: this.cwd,
|
|
727
|
+
timeoutMs
|
|
728
|
+
},
|
|
729
|
+
(event) => {
|
|
730
|
+
void handleProgress(event);
|
|
731
|
+
}
|
|
732
|
+
);
|
|
733
|
+
let timeoutHandle;
|
|
734
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
735
|
+
timeoutHandle = setTimeout(() => {
|
|
736
|
+
resolve({
|
|
737
|
+
taskId,
|
|
738
|
+
sessionId,
|
|
739
|
+
status: "timeout",
|
|
740
|
+
summary: `Task timed out after ${timeoutMs}ms`,
|
|
741
|
+
changedFiles: [],
|
|
742
|
+
errorCode: ProviderErrorCodes.ProviderTimeout,
|
|
743
|
+
errorMessage: `Provider timed out after ${timeoutMs}ms`
|
|
744
|
+
});
|
|
745
|
+
}, timeoutMs);
|
|
746
|
+
});
|
|
747
|
+
const result = await Promise.race([providerTaskPromise, timeoutPromise]);
|
|
748
|
+
const normalizedResult = {
|
|
749
|
+
...result,
|
|
750
|
+
taskId,
|
|
751
|
+
sessionId
|
|
752
|
+
};
|
|
753
|
+
terminalCommitted = true;
|
|
754
|
+
if (timeoutHandle) {
|
|
755
|
+
clearTimeout(timeoutHandle);
|
|
756
|
+
}
|
|
757
|
+
await this.persistTerminalState(
|
|
758
|
+
task,
|
|
759
|
+
normalizedResult,
|
|
760
|
+
provider,
|
|
761
|
+
model,
|
|
762
|
+
dryRun
|
|
763
|
+
);
|
|
764
|
+
this.inFlight.delete(key);
|
|
765
|
+
return normalizedResult;
|
|
766
|
+
} catch (error) {
|
|
767
|
+
const message = error instanceof Error ? error.message : "Unknown provider error";
|
|
768
|
+
const failedResult = {
|
|
769
|
+
taskId,
|
|
770
|
+
sessionId,
|
|
771
|
+
status: "error",
|
|
772
|
+
summary: "Provider execution failed",
|
|
773
|
+
changedFiles: [],
|
|
774
|
+
errorCode: ProviderErrorCodes.ProcessFailed,
|
|
775
|
+
errorMessage: message
|
|
776
|
+
};
|
|
777
|
+
terminalCommitted = true;
|
|
778
|
+
await this.persistTerminalState(
|
|
779
|
+
task,
|
|
780
|
+
failedResult,
|
|
781
|
+
provider,
|
|
782
|
+
model,
|
|
783
|
+
dryRun
|
|
784
|
+
);
|
|
785
|
+
this.inFlight.delete(key);
|
|
786
|
+
return failedResult;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
buildPrompt(task) {
|
|
790
|
+
const selectedAttributes = JSON.stringify(
|
|
791
|
+
task.uiChangePacket.element.attributes
|
|
792
|
+
);
|
|
793
|
+
return [
|
|
794
|
+
"You are implementing a UI change request from Pinpatch.",
|
|
795
|
+
"Scope guardrails (must follow):",
|
|
796
|
+
"- Implement only the requested UI change.",
|
|
797
|
+
"- Treat the selected element as the primary edit target by default.",
|
|
798
|
+
"- Do not move the change to ancestors, siblings, or page/global wrappers unless the request explicitly asks for a page-level or app-wide change or the selected element cannot satisfy the request without broader changes.",
|
|
799
|
+
"- If the request is ambiguous, prefer changing the selected element directly.",
|
|
800
|
+
"- Do not edit, reformat, or reorganize unrelated files.",
|
|
801
|
+
"- Never revert, overwrite, or clean up unrelated repo changes (other agents may be editing in parallel).",
|
|
802
|
+
"- If unrelated files are modified or dirty, leave them untouched.",
|
|
803
|
+
"- If you cannot complete the request without touching unrelated areas, stop and report the exact blocker.",
|
|
804
|
+
`User request: ${task.pin.body}`,
|
|
805
|
+
`Page URL: ${task.url}`,
|
|
806
|
+
`Element: <${task.uiChangePacket.element.tag}> text="${task.uiChangePacket.element.text ?? ""}"`,
|
|
807
|
+
`Element attributes: ${selectedAttributes}`,
|
|
808
|
+
`Bounding box: ${JSON.stringify(task.uiChangePacket.element.boundingBox)}`,
|
|
809
|
+
`Nearby text: ${task.uiChangePacket.nearbyText.join(" | ")}`,
|
|
810
|
+
`DOM snippet: ${task.uiChangePacket.domSnippet}`,
|
|
811
|
+
`Computed style summary: ${JSON.stringify(task.uiChangePacket.computedStyleSummary)}`,
|
|
812
|
+
`Screenshot path: ${task.screenshotPath}`,
|
|
813
|
+
"Apply the change in local files.",
|
|
814
|
+
"Output format (must follow):",
|
|
815
|
+
"- For each modified file, print a line exactly as: CHANGED: <path>",
|
|
816
|
+
"- Final line must be exactly one sentence summarizing the changes made."
|
|
817
|
+
].join("\n");
|
|
818
|
+
}
|
|
819
|
+
async persistTerminalState(task, result, provider, model, dryRun) {
|
|
820
|
+
const finishedAt = nowIso();
|
|
821
|
+
await this.store.updateSession(result.sessionId, (session) => ({
|
|
822
|
+
...session,
|
|
823
|
+
provider,
|
|
824
|
+
model,
|
|
825
|
+
dryRun,
|
|
826
|
+
status: result.status,
|
|
827
|
+
summary: result.summary,
|
|
828
|
+
changedFiles: result.changedFiles,
|
|
829
|
+
errorCode: result.errorCode,
|
|
830
|
+
errorMessage: result.errorMessage,
|
|
831
|
+
endedAt: finishedAt,
|
|
832
|
+
updatedAt: finishedAt,
|
|
833
|
+
events: [
|
|
834
|
+
...session.events,
|
|
835
|
+
{
|
|
836
|
+
status: result.status,
|
|
837
|
+
message: result.summary,
|
|
838
|
+
timestamp: finishedAt
|
|
839
|
+
}
|
|
840
|
+
]
|
|
841
|
+
}));
|
|
842
|
+
await this.store.updateTask(task.taskId, (current) => ({
|
|
843
|
+
...current,
|
|
844
|
+
status: result.status,
|
|
845
|
+
updatedAt: finishedAt,
|
|
846
|
+
provider,
|
|
847
|
+
model,
|
|
848
|
+
summary: result.summary,
|
|
849
|
+
changedFiles: result.changedFiles,
|
|
850
|
+
errorCode: result.errorCode,
|
|
851
|
+
errorMessage: result.errorMessage
|
|
852
|
+
}));
|
|
853
|
+
this.eventBus.publish(task.taskId, result.sessionId, {
|
|
854
|
+
type: "terminal",
|
|
855
|
+
taskId: task.taskId,
|
|
856
|
+
sessionId: result.sessionId,
|
|
857
|
+
status: result.status,
|
|
858
|
+
summary: result.summary,
|
|
859
|
+
changedFiles: result.changedFiles,
|
|
860
|
+
errorCode: result.errorCode,
|
|
861
|
+
errorMessage: result.errorMessage,
|
|
862
|
+
timestamp: finishedAt
|
|
863
|
+
});
|
|
864
|
+
this.logger.info(result.summary, {
|
|
865
|
+
component: "runner",
|
|
866
|
+
taskId: task.taskId,
|
|
867
|
+
sessionId: result.sessionId,
|
|
868
|
+
event: "task.terminal",
|
|
869
|
+
meta: {
|
|
870
|
+
status: result.status,
|
|
871
|
+
changedFiles: result.changedFiles,
|
|
872
|
+
errorCode: result.errorCode
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// src/bridge/event-bus.ts
|
|
879
|
+
import { EventEmitter } from "events";
|
|
880
|
+
var keyFor = (taskId, sessionId) => `${taskId}:${sessionId}`;
|
|
881
|
+
var TaskEventBus = class {
|
|
882
|
+
emitter = new EventEmitter();
|
|
883
|
+
subscribe(taskId, sessionId, listener) {
|
|
884
|
+
const key = keyFor(taskId, sessionId);
|
|
885
|
+
this.emitter.on(key, listener);
|
|
886
|
+
return () => {
|
|
887
|
+
this.emitter.off(key, listener);
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
publish(taskId, sessionId, event) {
|
|
891
|
+
this.emitter.emit(keyFor(taskId, sessionId), event);
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/bridge/server.ts
|
|
896
|
+
import { createServer } from "http";
|
|
897
|
+
import path4 from "path";
|
|
898
|
+
import { promises as fs4 } from "fs";
|
|
899
|
+
import express from "express";
|
|
900
|
+
import cors from "cors";
|
|
901
|
+
var sanitizeTaskId = (candidate) => candidate.replace(/[^a-zA-Z0-9-_]/g, "-").slice(0, 64);
|
|
902
|
+
var serializeSse = (eventName, payload) => {
|
|
903
|
+
return `event: ${eventName}
|
|
904
|
+
data: ${JSON.stringify(payload)}
|
|
905
|
+
|
|
906
|
+
`;
|
|
907
|
+
};
|
|
908
|
+
var fallbackOverlayScript = `
|
|
909
|
+
(function(){
|
|
910
|
+
if (window.__PINPATCH_OVERLAY_FALLBACK__) return;
|
|
911
|
+
window.__PINPATCH_OVERLAY_FALLBACK__ = true;
|
|
912
|
+
console.warn('[pinpatch] overlay bundle is missing. Build apps/overlay first.');
|
|
913
|
+
})();
|
|
914
|
+
`;
|
|
915
|
+
var resolveAvailableTaskId = async (store, initialTaskId) => {
|
|
916
|
+
if (initialTaskId) {
|
|
917
|
+
const candidate = sanitizeTaskId(initialTaskId);
|
|
918
|
+
const existing = await store.getTask(candidate);
|
|
919
|
+
if (!existing) {
|
|
920
|
+
return candidate;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
let tries = 0;
|
|
924
|
+
while (tries < 10) {
|
|
925
|
+
const candidate = generateTaskId();
|
|
926
|
+
const existing = await store.getTask(candidate);
|
|
927
|
+
if (!existing) {
|
|
928
|
+
return candidate;
|
|
929
|
+
}
|
|
930
|
+
tries += 1;
|
|
931
|
+
}
|
|
932
|
+
throw new Error("Failed to allocate a unique task id");
|
|
933
|
+
};
|
|
934
|
+
var createBridgeServer = (options) => {
|
|
935
|
+
const app = express();
|
|
936
|
+
const eventBus = new TaskEventBus();
|
|
937
|
+
const taskRunner = new TaskRunner({
|
|
938
|
+
cwd: options.cwd,
|
|
939
|
+
store: options.store,
|
|
940
|
+
logger: options.logger,
|
|
941
|
+
eventBus,
|
|
942
|
+
getProviderAdapter: options.getProviderAdapter
|
|
943
|
+
});
|
|
944
|
+
app.use(cors());
|
|
945
|
+
app.use(express.json({ limit: "25mb" }));
|
|
946
|
+
app.get("/health", (_req, res) => {
|
|
947
|
+
res.status(200).json({ ok: true });
|
|
948
|
+
});
|
|
949
|
+
app.get("/overlay.js", async (_req, res) => {
|
|
950
|
+
const requestedPath = options.overlayScriptPath;
|
|
951
|
+
if (requestedPath) {
|
|
952
|
+
try {
|
|
953
|
+
const script = await fs4.readFile(path4.resolve(requestedPath), "utf8");
|
|
954
|
+
res.setHeader("content-type", "application/javascript; charset=utf-8");
|
|
955
|
+
res.status(200).send(script);
|
|
956
|
+
return;
|
|
957
|
+
} catch {
|
|
958
|
+
options.logger.warn(
|
|
959
|
+
"Overlay bundle not found, serving fallback overlay script",
|
|
960
|
+
{
|
|
961
|
+
component: "bridge",
|
|
962
|
+
event: "overlay.fallback",
|
|
963
|
+
meta: {
|
|
964
|
+
overlayScriptPath: requestedPath
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
res.setHeader("content-type", "application/javascript; charset=utf-8");
|
|
971
|
+
res.status(200).send(fallbackOverlayScript);
|
|
972
|
+
});
|
|
973
|
+
app.post("/api/tasks", async (req, res) => {
|
|
974
|
+
const parsed = CreateTaskRequestSchema.safeParse(req.body);
|
|
975
|
+
if (!parsed.success) {
|
|
976
|
+
res.status(400).json({
|
|
977
|
+
error: "Invalid request",
|
|
978
|
+
details: parsed.error.flatten()
|
|
979
|
+
});
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
const payload = parsed.data;
|
|
983
|
+
const taskId = await resolveAvailableTaskId(
|
|
984
|
+
options.store,
|
|
985
|
+
payload.clientTaskId
|
|
986
|
+
);
|
|
987
|
+
let screenshotPath = payload.screenshotPath;
|
|
988
|
+
if (payload.screenshotDataUrl) {
|
|
989
|
+
screenshotPath = await options.store.writeScreenshot(
|
|
990
|
+
taskId,
|
|
991
|
+
payload.screenshotDataUrl
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
const createdAt = nowIso();
|
|
995
|
+
const taskRecord = {
|
|
996
|
+
taskId,
|
|
997
|
+
createdAt,
|
|
998
|
+
updatedAt: createdAt,
|
|
999
|
+
status: "created",
|
|
1000
|
+
url: payload.url,
|
|
1001
|
+
viewport: payload.viewport,
|
|
1002
|
+
pin: payload.pin,
|
|
1003
|
+
uiChangePacket: {
|
|
1004
|
+
...payload.uiChangePacket,
|
|
1005
|
+
screenshotPath,
|
|
1006
|
+
userRequest: payload.pin.body
|
|
1007
|
+
},
|
|
1008
|
+
screenshotPath,
|
|
1009
|
+
sessions: [payload.sessionId],
|
|
1010
|
+
latestSessionId: payload.sessionId,
|
|
1011
|
+
changedFiles: []
|
|
1012
|
+
};
|
|
1013
|
+
await options.store.createTask(taskRecord);
|
|
1014
|
+
options.logger.info("Task created", {
|
|
1015
|
+
component: "bridge",
|
|
1016
|
+
taskId,
|
|
1017
|
+
sessionId: payload.sessionId,
|
|
1018
|
+
event: "task.created",
|
|
1019
|
+
meta: {
|
|
1020
|
+
screenshotPath
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
res.status(201).json({
|
|
1024
|
+
taskId,
|
|
1025
|
+
sessionId: payload.sessionId,
|
|
1026
|
+
status: "created",
|
|
1027
|
+
taskPath: `.pinpatch/tasks/${taskId}.json`,
|
|
1028
|
+
eventsUrl: `/api/tasks/${taskId}/events?sessionId=${payload.sessionId}`
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
app.post("/api/tasks/:taskId/submit", async (req, res) => {
|
|
1032
|
+
const taskId = String(req.params.taskId ?? "");
|
|
1033
|
+
if (!taskId) {
|
|
1034
|
+
res.status(400).json({ error: "taskId is required" });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const task = await options.store.getTask(taskId);
|
|
1038
|
+
if (!task) {
|
|
1039
|
+
res.status(404).json({ error: "Task not found" });
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const parsed = SubmitTaskRequestSchema.safeParse(req.body);
|
|
1043
|
+
if (!parsed.success) {
|
|
1044
|
+
res.status(400).json({
|
|
1045
|
+
error: "Invalid request",
|
|
1046
|
+
details: parsed.error.flatten()
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const payload = parsed.data;
|
|
1051
|
+
if (payload.followUpBody) {
|
|
1052
|
+
const updatedAt = nowIso();
|
|
1053
|
+
const followUpBody = payload.followUpBody;
|
|
1054
|
+
await options.store.updateTask(taskId, (current) => ({
|
|
1055
|
+
...current,
|
|
1056
|
+
updatedAt,
|
|
1057
|
+
pin: {
|
|
1058
|
+
...current.pin,
|
|
1059
|
+
body: followUpBody
|
|
1060
|
+
},
|
|
1061
|
+
uiChangePacket: {
|
|
1062
|
+
...current.uiChangePacket,
|
|
1063
|
+
userRequest: followUpBody
|
|
1064
|
+
}
|
|
1065
|
+
}));
|
|
1066
|
+
}
|
|
1067
|
+
void taskRunner.runTask({
|
|
1068
|
+
taskId,
|
|
1069
|
+
sessionId: payload.sessionId,
|
|
1070
|
+
provider: payload.provider,
|
|
1071
|
+
model: payload.model,
|
|
1072
|
+
dryRun: payload.dryRun,
|
|
1073
|
+
debug: payload.debug
|
|
1074
|
+
}).catch((error) => {
|
|
1075
|
+
options.logger.error("Provider task execution failed", {
|
|
1076
|
+
component: "bridge",
|
|
1077
|
+
taskId,
|
|
1078
|
+
sessionId: payload.sessionId,
|
|
1079
|
+
event: "task.run.error",
|
|
1080
|
+
meta: {
|
|
1081
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
const acceptedAt = nowIso();
|
|
1086
|
+
res.status(202).json({
|
|
1087
|
+
taskId,
|
|
1088
|
+
sessionId: payload.sessionId,
|
|
1089
|
+
status: "queued",
|
|
1090
|
+
acceptedAt,
|
|
1091
|
+
eventsUrl: `/api/tasks/${taskId}/events?sessionId=${payload.sessionId}`
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
app.post("/api/tasks/:taskId/cancel", async (req, res) => {
|
|
1095
|
+
const taskId = String(req.params.taskId ?? "");
|
|
1096
|
+
const sessionId = String(req.body?.sessionId ?? "");
|
|
1097
|
+
if (!taskId || !sessionId) {
|
|
1098
|
+
res.status(400).json({ error: "taskId and sessionId are required" });
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
await taskRunner.cancelTask(taskId, sessionId);
|
|
1102
|
+
res.status(202).json({ taskId, sessionId, status: "cancelled" });
|
|
1103
|
+
});
|
|
1104
|
+
app.get("/api/tasks/:taskId/events", async (req, res) => {
|
|
1105
|
+
const taskId = String(req.params.taskId ?? "");
|
|
1106
|
+
const sessionId = String(req.query.sessionId ?? "");
|
|
1107
|
+
if (!taskId || !sessionId) {
|
|
1108
|
+
res.status(400).json({ error: "taskId and sessionId query params are required" });
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1112
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1113
|
+
res.setHeader("Connection", "keep-alive");
|
|
1114
|
+
res.flushHeaders();
|
|
1115
|
+
const push = (event) => {
|
|
1116
|
+
const type = event.type === "terminal" ? "terminal" : event.type === "heartbeat" ? "heartbeat" : "progress";
|
|
1117
|
+
res.write(serializeSse(type, event));
|
|
1118
|
+
};
|
|
1119
|
+
push({
|
|
1120
|
+
type: "heartbeat",
|
|
1121
|
+
timestamp: nowIso()
|
|
1122
|
+
});
|
|
1123
|
+
const unsubscribe = eventBus.subscribe(taskId, sessionId, push);
|
|
1124
|
+
const heartbeat = setInterval(() => {
|
|
1125
|
+
push({
|
|
1126
|
+
type: "heartbeat",
|
|
1127
|
+
timestamp: nowIso()
|
|
1128
|
+
});
|
|
1129
|
+
}, 15e3);
|
|
1130
|
+
req.on("close", () => {
|
|
1131
|
+
clearInterval(heartbeat);
|
|
1132
|
+
unsubscribe();
|
|
1133
|
+
res.end();
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
const server = createServer(app);
|
|
1137
|
+
return {
|
|
1138
|
+
app,
|
|
1139
|
+
server,
|
|
1140
|
+
eventBus,
|
|
1141
|
+
taskRunner,
|
|
1142
|
+
async start() {
|
|
1143
|
+
await new Promise((resolve, reject) => {
|
|
1144
|
+
server.once("error", reject);
|
|
1145
|
+
server.listen(options.port, () => {
|
|
1146
|
+
server.off("error", reject);
|
|
1147
|
+
resolve();
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
options.logger.info(
|
|
1151
|
+
`Bridge listening on http://localhost:${options.port}`,
|
|
1152
|
+
{
|
|
1153
|
+
component: "bridge",
|
|
1154
|
+
event: "bridge.started"
|
|
1155
|
+
}
|
|
1156
|
+
);
|
|
1157
|
+
},
|
|
1158
|
+
async stop() {
|
|
1159
|
+
await new Promise((resolve) => {
|
|
1160
|
+
server.close(() => resolve());
|
|
1161
|
+
});
|
|
1162
|
+
options.logger.info("Bridge stopped", {
|
|
1163
|
+
component: "bridge",
|
|
1164
|
+
event: "bridge.stopped"
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
// src/logging/logger.ts
|
|
1171
|
+
import os from "os";
|
|
1172
|
+
import path5 from "path";
|
|
1173
|
+
import { existsSync, statSync } from "fs";
|
|
1174
|
+
var redactValue = (value) => {
|
|
1175
|
+
if (typeof value === "string") {
|
|
1176
|
+
const home = os.homedir();
|
|
1177
|
+
return value.replaceAll(home, "~").replace(/(Bearer\s+)[A-Za-z0-9._-]+/gi, "$1[REDACTED]").replace(/(token=)[^\s&]+/gi, "$1[REDACTED]");
|
|
1178
|
+
}
|
|
1179
|
+
if (Array.isArray(value)) {
|
|
1180
|
+
return value.map((entry) => redactValue(entry));
|
|
1181
|
+
}
|
|
1182
|
+
if (value && typeof value === "object") {
|
|
1183
|
+
const output = {};
|
|
1184
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
1185
|
+
if (["token", "authorization", "auth", "apiKey", "apikey"].includes(key.toLowerCase())) {
|
|
1186
|
+
output[key] = "[REDACTED]";
|
|
1187
|
+
} else {
|
|
1188
|
+
output[key] = redactValue(fieldValue);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return output;
|
|
1192
|
+
}
|
|
1193
|
+
return value;
|
|
1194
|
+
};
|
|
1195
|
+
var getDateKey = (dateIso) => dateIso.slice(0, 10);
|
|
1196
|
+
var resolveLogPath = (logsDir, timestamp, maxFileSizeBytes) => {
|
|
1197
|
+
const dateKey = getDateKey(timestamp);
|
|
1198
|
+
let index = 0;
|
|
1199
|
+
while (true) {
|
|
1200
|
+
const candidateName = index === 0 ? `${dateKey}.jsonl` : `${dateKey}-${index}.jsonl`;
|
|
1201
|
+
const candidatePath = path5.join(logsDir, candidateName);
|
|
1202
|
+
if (!existsSync(candidatePath)) {
|
|
1203
|
+
return candidatePath;
|
|
1204
|
+
}
|
|
1205
|
+
const size = statSync(candidatePath).size;
|
|
1206
|
+
if (size < maxFileSizeBytes) {
|
|
1207
|
+
return candidatePath;
|
|
1208
|
+
}
|
|
1209
|
+
index += 1;
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
var createLogger = (options) => {
|
|
1213
|
+
const { store, debugEnabled, component, maxFileSizeBytes = 2 * 1024 * 1024 } = options;
|
|
1214
|
+
const emit = (level, message, details) => {
|
|
1215
|
+
if (level === "debug" && !debugEnabled) {
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const timestamp = nowIso();
|
|
1219
|
+
const payload = RuntimeLogEventSchema.parse({
|
|
1220
|
+
timestamp,
|
|
1221
|
+
level,
|
|
1222
|
+
component: details?.component ?? component,
|
|
1223
|
+
taskId: details?.taskId,
|
|
1224
|
+
sessionId: details?.sessionId,
|
|
1225
|
+
event: details?.event ?? "log",
|
|
1226
|
+
message,
|
|
1227
|
+
meta: redactValue(details?.meta ?? {})
|
|
1228
|
+
});
|
|
1229
|
+
const line = `[${payload.timestamp}] ${payload.level.toUpperCase()} ${payload.component}: ${payload.message}`;
|
|
1230
|
+
if (level === "error") {
|
|
1231
|
+
console.error(line);
|
|
1232
|
+
} else if (level === "warn") {
|
|
1233
|
+
console.warn(line);
|
|
1234
|
+
} else {
|
|
1235
|
+
console.log(line);
|
|
1236
|
+
}
|
|
1237
|
+
const logPath = resolveLogPath(store.logsDir, timestamp, maxFileSizeBytes);
|
|
1238
|
+
void store.appendLog(logPath, payload);
|
|
1239
|
+
};
|
|
1240
|
+
return {
|
|
1241
|
+
debug: (message, details) => emit("debug", message, details),
|
|
1242
|
+
info: (message, details) => emit("info", message, details),
|
|
1243
|
+
warn: (message, details) => emit("warn", message, details),
|
|
1244
|
+
error: (message, details) => emit("error", message, details)
|
|
1245
|
+
};
|
|
1246
|
+
};
|
|
1247
|
+
export {
|
|
1248
|
+
ArtifactStore,
|
|
1249
|
+
BoundingBoxSchema,
|
|
1250
|
+
CreateTaskRequestSchema,
|
|
1251
|
+
CreateTaskResponseSchema,
|
|
1252
|
+
DEFAULT_CONFIG,
|
|
1253
|
+
ElementDescriptorSchema,
|
|
1254
|
+
PinStateSchema,
|
|
1255
|
+
PinpatchConfigSchema,
|
|
1256
|
+
ProviderErrorCodes,
|
|
1257
|
+
ProviderNameSchema,
|
|
1258
|
+
ProviderProgressSchema,
|
|
1259
|
+
ProviderProgressStatusSchema,
|
|
1260
|
+
ProviderResultSchema,
|
|
1261
|
+
ProviderTerminalStatusSchema,
|
|
1262
|
+
RuntimeLogEventSchema,
|
|
1263
|
+
RuntimeLogLevelSchema,
|
|
1264
|
+
SessionEventSchema,
|
|
1265
|
+
SessionRecordSchema,
|
|
1266
|
+
SseEventSchema,
|
|
1267
|
+
SseHeartbeatEventSchema,
|
|
1268
|
+
SseProgressEventSchema,
|
|
1269
|
+
SseTerminalEventSchema,
|
|
1270
|
+
SubmitTaskRequestSchema,
|
|
1271
|
+
SubmitTaskResponseSchema,
|
|
1272
|
+
TaskEventBus,
|
|
1273
|
+
TaskPinSchema,
|
|
1274
|
+
TaskRecordSchema,
|
|
1275
|
+
TaskRunner,
|
|
1276
|
+
TaskStatusSchema,
|
|
1277
|
+
UiChangePacketSchema,
|
|
1278
|
+
ViewportSchema,
|
|
1279
|
+
createBridgeServer,
|
|
1280
|
+
createLogger,
|
|
1281
|
+
ensureConfigFile,
|
|
1282
|
+
generateSessionId,
|
|
1283
|
+
generateTaskId,
|
|
1284
|
+
getConfigPath,
|
|
1285
|
+
nowIso,
|
|
1286
|
+
readConfigFile,
|
|
1287
|
+
resolveConfig
|
|
1288
|
+
};
|
|
1289
|
+
//# sourceMappingURL=index.js.map
|