@jxtools/promptline 1.3.9 → 1.3.11
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/package.json +1 -1
- package/promptline-prompt-queue.sh +104 -68
- package/src/backend/queue-store.ts +40 -1
- package/src/components/AddPromptForm.tsx +1 -1
- package/vite-plugin-api.ts +44 -26
package/package.json
CHANGED
|
@@ -52,9 +52,40 @@ RESULT=$(python3 << 'PYEOF'
|
|
|
52
52
|
import json
|
|
53
53
|
import sys
|
|
54
54
|
import os
|
|
55
|
+
import time
|
|
55
56
|
import tempfile
|
|
56
57
|
from datetime import datetime, timezone
|
|
57
58
|
|
|
59
|
+
|
|
60
|
+
def acquire_lock(lock_path, timeout=3):
|
|
61
|
+
deadline = time.time() + timeout
|
|
62
|
+
while True:
|
|
63
|
+
try:
|
|
64
|
+
fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
65
|
+
os.close(fd)
|
|
66
|
+
return
|
|
67
|
+
except FileExistsError:
|
|
68
|
+
try:
|
|
69
|
+
if time.time() - os.path.getmtime(lock_path) > 10:
|
|
70
|
+
os.unlink(lock_path)
|
|
71
|
+
continue
|
|
72
|
+
except OSError:
|
|
73
|
+
continue
|
|
74
|
+
if time.time() >= deadline:
|
|
75
|
+
try:
|
|
76
|
+
os.unlink(lock_path)
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
return
|
|
80
|
+
time.sleep(0.01)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def release_lock(lock_path):
|
|
84
|
+
try:
|
|
85
|
+
os.unlink(lock_path)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
58
89
|
def atomic_write(path, obj):
|
|
59
90
|
"""Write JSON atomically: temp file + rename to prevent corruption."""
|
|
60
91
|
dir_name = os.path.dirname(path)
|
|
@@ -105,82 +136,87 @@ if not queue_file:
|
|
|
105
136
|
print("STOP")
|
|
106
137
|
sys.exit(0)
|
|
107
138
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
project = os.environ.get("PROJECT", "")
|
|
111
|
-
now = datetime.now(timezone.utc).isoformat()
|
|
112
|
-
data = {
|
|
113
|
-
"sessionId": session_id,
|
|
114
|
-
"project": project,
|
|
115
|
-
"directory": cwd,
|
|
116
|
-
"sessionName": extract_session_name(transcript_path),
|
|
117
|
-
"prompts": [],
|
|
118
|
-
"startedAt": now,
|
|
119
|
-
"lastActivity": now,
|
|
120
|
-
"currentPromptId": None,
|
|
121
|
-
"completedAt": None,
|
|
122
|
-
"closedAt": None,
|
|
123
|
-
}
|
|
124
|
-
atomic_write(queue_file, data)
|
|
125
|
-
print("STOP")
|
|
126
|
-
sys.exit(0)
|
|
127
|
-
|
|
139
|
+
lock_path = queue_file + ".lock"
|
|
140
|
+
acquire_lock(lock_path)
|
|
128
141
|
try:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
if not os.path.isfile(queue_file):
|
|
143
|
+
cwd = os.environ.get("CWD", "")
|
|
144
|
+
project = os.environ.get("PROJECT", "")
|
|
145
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
146
|
+
data = {
|
|
147
|
+
"sessionId": session_id,
|
|
148
|
+
"project": project,
|
|
149
|
+
"directory": cwd,
|
|
150
|
+
"sessionName": extract_session_name(transcript_path),
|
|
151
|
+
"prompts": [],
|
|
152
|
+
"startedAt": now,
|
|
153
|
+
"lastActivity": now,
|
|
154
|
+
"currentPromptId": None,
|
|
155
|
+
"completedAt": None,
|
|
156
|
+
"closedAt": None,
|
|
157
|
+
}
|
|
158
|
+
atomic_write(queue_file, data)
|
|
159
|
+
print("STOP")
|
|
160
|
+
sys.exit(0)
|
|
143
161
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if not data.get("sessionName"):
|
|
151
|
-
data["sessionName"] = extract_session_name(transcript_path)
|
|
152
|
-
|
|
153
|
-
# Step 2: Find the first "pending" prompt
|
|
154
|
-
next_prompt = None
|
|
155
|
-
for p in prompts:
|
|
156
|
-
if p.get("status") == "pending":
|
|
157
|
-
next_prompt = p
|
|
158
|
-
break
|
|
162
|
+
try:
|
|
163
|
+
with open(queue_file, "r") as f:
|
|
164
|
+
data = json.load(f)
|
|
165
|
+
except (json.JSONDecodeError, IOError):
|
|
166
|
+
print("0")
|
|
167
|
+
sys.exit(0)
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
prompts = data.get("prompts", [])
|
|
170
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
162
171
|
|
|
163
|
-
|
|
172
|
+
# Step 1: Mark any currently "running" prompts as "completed"
|
|
173
|
+
for p in prompts:
|
|
174
|
+
if p.get("status") == "running":
|
|
175
|
+
p["status"] = "completed"
|
|
176
|
+
p["completedAt"] = now
|
|
177
|
+
|
|
178
|
+
# Step 1b: Track completedAt when all prompts are done
|
|
179
|
+
all_done = all(p.get("status") == "completed" for p in prompts) and len(prompts) > 0
|
|
180
|
+
if all_done and not data.get("completedAt"):
|
|
181
|
+
data["completedAt"] = now
|
|
182
|
+
|
|
183
|
+
# Step 1c: Update sessionName if still null
|
|
184
|
+
if not data.get("sessionName"):
|
|
185
|
+
data["sessionName"] = extract_session_name(transcript_path)
|
|
186
|
+
|
|
187
|
+
# Step 2: Find the first "pending" prompt
|
|
188
|
+
next_prompt = None
|
|
189
|
+
for p in prompts:
|
|
190
|
+
if p.get("status") == "pending":
|
|
191
|
+
next_prompt = p
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
# Step 3: Update session tracking
|
|
195
|
+
data["lastActivity"] = now
|
|
196
|
+
|
|
197
|
+
if next_prompt is None:
|
|
198
|
+
data["prompts"] = prompts
|
|
199
|
+
data["currentPromptId"] = None
|
|
200
|
+
atomic_write(queue_file, data)
|
|
201
|
+
print("STOP")
|
|
202
|
+
sys.exit(0)
|
|
203
|
+
|
|
204
|
+
# We have a pending prompt -> mark it as running
|
|
205
|
+
next_prompt["status"] = "running"
|
|
206
|
+
data["currentPromptId"] = next_prompt["id"]
|
|
164
207
|
data["prompts"] = prompts
|
|
165
|
-
data["currentPromptId"] = None
|
|
166
|
-
atomic_write(queue_file, data)
|
|
167
|
-
print("STOP")
|
|
168
|
-
sys.exit(0)
|
|
169
|
-
|
|
170
|
-
# We have a pending prompt -> mark it as running
|
|
171
|
-
next_prompt["status"] = "running"
|
|
172
|
-
data["currentPromptId"] = next_prompt["id"]
|
|
173
|
-
data["prompts"] = prompts
|
|
174
208
|
|
|
175
|
-
atomic_write(queue_file, data)
|
|
209
|
+
atomic_write(queue_file, data)
|
|
176
210
|
|
|
177
|
-
# Count remaining pending prompts (excluding the one we just took)
|
|
178
|
-
remaining = sum(1 for p in prompts if p.get("status") == "pending")
|
|
211
|
+
# Count remaining pending prompts (excluding the one we just took)
|
|
212
|
+
remaining = sum(1 for p in prompts if p.get("status") == "pending")
|
|
179
213
|
|
|
180
|
-
reason = f"PromptLine ({remaining} queued)\n\n{next_prompt['text']}"
|
|
181
|
-
decision = {"decision": "block", "reason": reason}
|
|
182
|
-
print("CONTINUE")
|
|
183
|
-
print(json.dumps(decision))
|
|
214
|
+
reason = f"PromptLine ({remaining} queued)\n\n{next_prompt['text']}"
|
|
215
|
+
decision = {"decision": "block", "reason": reason}
|
|
216
|
+
print("CONTINUE")
|
|
217
|
+
print(json.dumps(decision))
|
|
218
|
+
finally:
|
|
219
|
+
release_lock(lock_path)
|
|
184
220
|
PYEOF
|
|
185
221
|
)
|
|
186
222
|
|
|
@@ -1,9 +1,48 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, rmSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, renameSync, rmSync, openSync, closeSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import type { SessionQueue, Prompt, PromptStatus, SessionStatus, QueueStatus, ProjectView, SessionWithStatus } from '../types/queue.ts';
|
|
4
4
|
|
|
5
5
|
export const SESSION_ACTIVE_TIMEOUT_MS = 60_000;
|
|
6
6
|
export const SESSION_ABANDONED_TIMEOUT_MS = 24 * 60 * 60_000; // 24h safety net
|
|
7
|
+
const LOCK_STALE_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
function acquireLockSync(lockPath: string): void {
|
|
10
|
+
for (let i = 0; i < 100; i++) {
|
|
11
|
+
try {
|
|
12
|
+
const fd = openSync(lockPath, 'wx');
|
|
13
|
+
closeSync(fd);
|
|
14
|
+
return;
|
|
15
|
+
} catch (err: unknown) {
|
|
16
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
|
17
|
+
try {
|
|
18
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
19
|
+
unlinkSync(lockPath);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
} catch { continue; }
|
|
23
|
+
const end = Date.now() + 10;
|
|
24
|
+
while (Date.now() < end) { /* spin */ }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function releaseLockSync(lockPath: string): void {
|
|
31
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function withSessionLock<T>(
|
|
35
|
+
queuesDir: string, project: string, sessionId: string,
|
|
36
|
+
fn: () => T,
|
|
37
|
+
): T {
|
|
38
|
+
const lockPath = sessionPath(queuesDir, project, sessionId) + '.lock';
|
|
39
|
+
acquireLockSync(lockPath);
|
|
40
|
+
try {
|
|
41
|
+
return fn();
|
|
42
|
+
} finally {
|
|
43
|
+
releaseLockSync(lockPath);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
7
46
|
|
|
8
47
|
export function ensureProjectDir(queuesDir: string, project: string): void {
|
|
9
48
|
mkdirSync(join(queuesDir, project), { recursive: true });
|
|
@@ -65,7 +65,7 @@ export function AddPromptForm({ project, sessionId, onAdded }: AddPromptFormProp
|
|
|
65
65
|
onClick={() => setExpanded(true)}
|
|
66
66
|
className={[
|
|
67
67
|
'w-full text-left text-xs text-[var(--color-muted)] px-4 py-2.5 cursor-pointer',
|
|
68
|
-
'border border-dashed border-[var(--color-
|
|
68
|
+
'border border-dashed border-[var(--color-active)]/40 rounded-lg',
|
|
69
69
|
'hover:border-[var(--color-active)]/40 hover:text-[var(--color-active)] hover:bg-[var(--color-active)]/5',
|
|
70
70
|
'transition-all duration-150 focus:outline-none focus:ring-1 focus:ring-[var(--color-active)]/30',
|
|
71
71
|
].join(' ')}
|
package/vite-plugin-api.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
deletePrompt,
|
|
20
20
|
clearPrompts,
|
|
21
21
|
reorderPrompts,
|
|
22
|
+
withSessionLock,
|
|
22
23
|
} from './src/backend/queue-store.ts';
|
|
23
24
|
|
|
24
25
|
const QUEUES_DIR = join(homedir(), '.promptline', 'queues');
|
|
@@ -232,8 +233,6 @@ async function handleApi(
|
|
|
232
233
|
) {
|
|
233
234
|
const project = segments[1];
|
|
234
235
|
const sessionId = segments[3];
|
|
235
|
-
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
236
|
-
if (!session) return jsonError(res, 404, 'Session not found');
|
|
237
236
|
|
|
238
237
|
const body = await parseBody(req);
|
|
239
238
|
const order = body.order as string[] | undefined;
|
|
@@ -241,11 +240,16 @@ async function handleApi(
|
|
|
241
240
|
return jsonError(res, 400, 'Field "order" (string[]) is required');
|
|
242
241
|
}
|
|
243
242
|
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
return withSessionLock(QUEUES_DIR, project, sessionId, () => {
|
|
244
|
+
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
245
|
+
if (!session) return jsonError(res, 404, 'Session not found');
|
|
246
|
+
|
|
247
|
+
const error = reorderPrompts(session, order);
|
|
248
|
+
if (error) return jsonError(res, 400, error);
|
|
246
249
|
|
|
247
|
-
|
|
248
|
-
|
|
250
|
+
writeSession(QUEUES_DIR, project, session);
|
|
251
|
+
return json(res, 200, session);
|
|
252
|
+
});
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
// /api/projects/:project/sessions/:sessionId/prompts/:promptId
|
|
@@ -256,8 +260,6 @@ async function handleApi(
|
|
|
256
260
|
const project = segments[1];
|
|
257
261
|
const sessionId = segments[3];
|
|
258
262
|
const promptId = segments[5];
|
|
259
|
-
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
260
|
-
if (!session) return jsonError(res, 404, 'Session not found');
|
|
261
263
|
|
|
262
264
|
if (method === 'PATCH') {
|
|
263
265
|
const body = await parseBody(req);
|
|
@@ -274,19 +276,29 @@ async function handleApi(
|
|
|
274
276
|
updates.status = body.status as PromptStatus;
|
|
275
277
|
}
|
|
276
278
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
return withSessionLock(QUEUES_DIR, project, sessionId, () => {
|
|
280
|
+
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
281
|
+
if (!session) return jsonError(res, 404, 'Session not found');
|
|
279
282
|
|
|
280
|
-
|
|
281
|
-
|
|
283
|
+
const updated = updatePrompt(session, promptId, updates);
|
|
284
|
+
if (!updated) return jsonError(res, 404, `Prompt "${promptId}" not found`);
|
|
285
|
+
|
|
286
|
+
writeSession(QUEUES_DIR, project, session);
|
|
287
|
+
return json(res, 200, updated);
|
|
288
|
+
});
|
|
282
289
|
}
|
|
283
290
|
|
|
284
291
|
if (method === 'DELETE') {
|
|
285
|
-
|
|
286
|
-
|
|
292
|
+
return withSessionLock(QUEUES_DIR, project, sessionId, () => {
|
|
293
|
+
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
294
|
+
if (!session) return jsonError(res, 404, 'Session not found');
|
|
287
295
|
|
|
288
|
-
|
|
289
|
-
|
|
296
|
+
const removed = deletePrompt(session, promptId);
|
|
297
|
+
if (!removed) return jsonError(res, 404, `Prompt "${promptId}" not found`);
|
|
298
|
+
|
|
299
|
+
writeSession(QUEUES_DIR, project, session);
|
|
300
|
+
return json(res, 200, removed);
|
|
301
|
+
});
|
|
290
302
|
}
|
|
291
303
|
|
|
292
304
|
return jsonError(res, 405, `Method ${method} not allowed`);
|
|
@@ -299,12 +311,15 @@ async function handleApi(
|
|
|
299
311
|
) {
|
|
300
312
|
const project = segments[1];
|
|
301
313
|
const sessionId = segments[3];
|
|
302
|
-
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
303
|
-
if (!session) return jsonError(res, 404, 'Session not found');
|
|
304
314
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
315
|
+
return withSessionLock(QUEUES_DIR, project, sessionId, () => {
|
|
316
|
+
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
317
|
+
if (!session) return jsonError(res, 404, 'Session not found');
|
|
318
|
+
|
|
319
|
+
const removed = clearPrompts(session);
|
|
320
|
+
writeSession(QUEUES_DIR, project, session);
|
|
321
|
+
return json(res, 200, { cleared: removed.length });
|
|
322
|
+
});
|
|
308
323
|
}
|
|
309
324
|
|
|
310
325
|
// /api/projects/:project/sessions/:sessionId/prompts
|
|
@@ -314,17 +329,20 @@ async function handleApi(
|
|
|
314
329
|
) {
|
|
315
330
|
const project = segments[1];
|
|
316
331
|
const sessionId = segments[3];
|
|
317
|
-
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
318
|
-
if (!session) return jsonError(res, 404, 'Session not found');
|
|
319
332
|
|
|
320
333
|
const body = await parseBody(req);
|
|
321
334
|
if (!body.text || typeof body.text !== 'string') {
|
|
322
335
|
return jsonError(res, 400, 'Field "text" (string) is required');
|
|
323
336
|
}
|
|
324
337
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
338
|
+
return withSessionLock(QUEUES_DIR, project, sessionId, () => {
|
|
339
|
+
const session = readSession(QUEUES_DIR, project, sessionId);
|
|
340
|
+
if (!session) return jsonError(res, 404, 'Session not found');
|
|
341
|
+
|
|
342
|
+
const prompt = addPrompt(session, uuidv4(), body.text as string);
|
|
343
|
+
writeSession(QUEUES_DIR, project, session);
|
|
344
|
+
return json(res, 201, prompt);
|
|
345
|
+
});
|
|
328
346
|
}
|
|
329
347
|
|
|
330
348
|
return jsonError(res, 404, 'Not found');
|