palmlist-web 0.2.8
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/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +528 -0
- package/package.json +24 -0
- package/src/index.ts +568 -0
- package/tsconfig.json +9 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palmlist Web - Simple HTTP server for viewing Projects, Tasks, Handoffs
|
|
3
|
+
* Pixel-style UI, URL-persisted filters, task detail page, status transitions
|
|
4
|
+
*/
|
|
5
|
+
import { listProjects, listTasks, listHandoffs, getTask, claimTask, startTask, completeTask, selfUnblockTask, createTask, } from 'palmlist-core';
|
|
6
|
+
import { TASK_STATUSES } from 'palmlist-types';
|
|
7
|
+
const PORT = Number(process.env.PORT) || 18008;
|
|
8
|
+
function parseQuery(url) {
|
|
9
|
+
const params = {};
|
|
10
|
+
const project = url.searchParams.get('project');
|
|
11
|
+
if (project)
|
|
12
|
+
params.project = project;
|
|
13
|
+
const status = url.searchParams.get('status');
|
|
14
|
+
if (status) {
|
|
15
|
+
const statuses = status.split(',').map((s) => s.trim()).filter(Boolean);
|
|
16
|
+
params.status = statuses.filter((s) => TASK_STATUSES.includes(s));
|
|
17
|
+
}
|
|
18
|
+
const owner = url.searchParams.get('owner');
|
|
19
|
+
if (owner)
|
|
20
|
+
params.owner = owner;
|
|
21
|
+
const taskType = url.searchParams.get('task_type');
|
|
22
|
+
if (taskType)
|
|
23
|
+
params.task_type = taskType;
|
|
24
|
+
return params;
|
|
25
|
+
}
|
|
26
|
+
function buildFilterUrl(base, overrides) {
|
|
27
|
+
const params = new URLSearchParams();
|
|
28
|
+
if (overrides.project)
|
|
29
|
+
params.set('project', overrides.project);
|
|
30
|
+
if (overrides.status?.length)
|
|
31
|
+
params.set('status', overrides.status.join(','));
|
|
32
|
+
if (overrides.owner)
|
|
33
|
+
params.set('owner', overrides.owner);
|
|
34
|
+
if (overrides.task_type)
|
|
35
|
+
params.set('task_type', overrides.task_type);
|
|
36
|
+
const qs = params.toString();
|
|
37
|
+
return qs ? `${base}?${qs}` : base;
|
|
38
|
+
}
|
|
39
|
+
function escapeHtml(s) {
|
|
40
|
+
return s
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"');
|
|
45
|
+
}
|
|
46
|
+
function taskCardTooltip(t) {
|
|
47
|
+
const parts = [];
|
|
48
|
+
if (t.description)
|
|
49
|
+
parts.push(escapeHtml(t.description));
|
|
50
|
+
if (t.acceptance_criteria?.length) {
|
|
51
|
+
parts.push('AC: ' + t.acceptance_criteria.map((ac) => escapeHtml(ac)).join('; '));
|
|
52
|
+
}
|
|
53
|
+
return parts.length ? parts.join('\n\n') : 'No description';
|
|
54
|
+
}
|
|
55
|
+
function renderTaskCard(t) {
|
|
56
|
+
const tooltip = taskCardTooltip(t);
|
|
57
|
+
const hasTooltip = tooltip !== 'No description';
|
|
58
|
+
const tooltipAttr = hasTooltip ? ` title="${tooltip.replace(/"/g, '"').replace(/\n/g, ' ')}"` : '';
|
|
59
|
+
const backUrl = '/';
|
|
60
|
+
return `<div class="task-card"${tooltipAttr}>
|
|
61
|
+
<a href="/task/${escapeHtml(t.id)}" class="task-link"><span class="task-id">${escapeHtml(t.id)}</span> [${t.owner}] ${escapeHtml(t.title)}</a>
|
|
62
|
+
${hasTooltip ? '<div class="task-preview">' + escapeHtml(t.description || '') + '</div>' : ''}
|
|
63
|
+
</div>`;
|
|
64
|
+
}
|
|
65
|
+
function renderHtml(filters, projects, tasks, handoffs) {
|
|
66
|
+
const base = '/';
|
|
67
|
+
const projectLinks = projects.map((p) => `<a href="${buildFilterUrl(base, { ...filters, project: p.id })}" class="filter-link ${filters.project === p.id ? 'active' : ''}">${escapeHtml(p.name)}</a>`);
|
|
68
|
+
const statusOpts = [
|
|
69
|
+
{ v: undefined, l: 'all' },
|
|
70
|
+
{ v: ['ready'], l: 'ready' },
|
|
71
|
+
{ v: ['in_progress'], l: 'in_progress' },
|
|
72
|
+
{ v: ['done'], l: 'done' },
|
|
73
|
+
{ v: ['ready', 'in_progress'], l: 'unfinished' },
|
|
74
|
+
];
|
|
75
|
+
const statusLinks = statusOpts.map((s) => `<a href="${buildFilterUrl(base, { ...filters, status: s.v })}" class="filter-link ${JSON.stringify(filters.status || []) === JSON.stringify(s.v || []) ? 'active' : ''}">${s.l}</a>`);
|
|
76
|
+
const ownerLinks = [
|
|
77
|
+
{ v: undefined, l: 'all' },
|
|
78
|
+
{ v: 'agent_worker', l: 'agent' },
|
|
79
|
+
{ v: 'human_worker', l: 'human' },
|
|
80
|
+
].map((o) => `<a href="${buildFilterUrl(base, { ...filters, owner: o.v })}" class="filter-link ${filters.owner === o.v ? 'active' : ''}">${o.l}</a>`);
|
|
81
|
+
const taskTypeLinks = [
|
|
82
|
+
undefined,
|
|
83
|
+
'implementation',
|
|
84
|
+
'testing',
|
|
85
|
+
'review',
|
|
86
|
+
'credentials',
|
|
87
|
+
'deploy',
|
|
88
|
+
'docs',
|
|
89
|
+
'custom',
|
|
90
|
+
].map((t) => `<a href="${buildFilterUrl(base, { ...filters, task_type: t })}" class="filter-link ${(filters.task_type || '') === (t || '') ? 'active' : ''}">${t || 'all'}</a>`);
|
|
91
|
+
const tasksByStatus = {
|
|
92
|
+
ready: tasks.filter((t) => t.status === 'ready'),
|
|
93
|
+
in_progress: tasks.filter((t) => t.status === 'in_progress'),
|
|
94
|
+
done: tasks.filter((t) => t.status === 'done'),
|
|
95
|
+
};
|
|
96
|
+
return `<!DOCTYPE html>
|
|
97
|
+
<html lang="en">
|
|
98
|
+
<head>
|
|
99
|
+
<meta charset="UTF-8">
|
|
100
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
101
|
+
<title>Palmlist</title>
|
|
102
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
103
|
+
<style>
|
|
104
|
+
* { box-sizing: border-box; }
|
|
105
|
+
body { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; -webkit-font-smoothing: antialiased; }
|
|
106
|
+
h1 { font-size: 20px; margin: 0 0 16px; border-bottom: 2px solid #4a4a6a; padding-bottom: 8px; }
|
|
107
|
+
h2 { font-size: 16px; margin: 24px 0 12px; color: #a0a0ff; }
|
|
108
|
+
.filters { margin-bottom: 16px; padding: 12px; background: #16213e; border: 1px solid #4a4a6a; }
|
|
109
|
+
.filters label { display: block; font-size: 13px; color: #888; margin-bottom: 4px; }
|
|
110
|
+
.filter-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
|
|
111
|
+
.filter-row:last-child { margin-bottom: 0; }
|
|
112
|
+
.filter-link { padding: 6px 10px; background: #0f3460; color: #eaeaea; text-decoration: none; border: 2px solid #4a4a6a; }
|
|
113
|
+
.filter-link:hover { background: #1a4a7a; }
|
|
114
|
+
.filter-link.active { background: #4a4a6a; border-color: #a0a0ff; }
|
|
115
|
+
.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
|
116
|
+
.column { background: #16213e; border: 1px solid #4a4a6a; padding: 12px; min-height: 120px; }
|
|
117
|
+
.column h3 { font-size: 14px; margin: 0 0 12px; color: #a0a0ff; }
|
|
118
|
+
.task-card { background: #0f3460; border: 1px solid #4a4a6a; padding: 8px; margin-bottom: 8px; font-size: 13px; cursor: pointer; }
|
|
119
|
+
.task-card:last-child { margin-bottom: 0; }
|
|
120
|
+
.task-card:hover { border-color: #6a6a8a; }
|
|
121
|
+
.task-link { color: #eaeaea; text-decoration: none; }
|
|
122
|
+
.task-link:hover { text-decoration: underline; }
|
|
123
|
+
.task-id { color: #888; font-size: 12px; }
|
|
124
|
+
.task-preview { font-size: 11px; color: #888; margin-top: 4px; max-height: 2.4em; overflow: hidden; }
|
|
125
|
+
.project-card { background: #16213e; border: 1px solid #4a4a6a; padding: 12px; margin-bottom: 8px; }
|
|
126
|
+
.template-context { margin-top: 8px; font-size: 12px; color: #888; }
|
|
127
|
+
.handoff-card { background: #16213e; border: 1px solid #4a4a6a; padding: 12px; margin-bottom: 8px; font-size: 13px; }
|
|
128
|
+
.handoff-card .summary { color: #b0b0b0; margin-top: 4px; }
|
|
129
|
+
table { width: 100%; border-collapse: collapse; }
|
|
130
|
+
th, td { padding: 8px; text-align: left; border: 1px solid #4a4a6a; }
|
|
131
|
+
th { background: #16213e; color: #a0a0ff; }
|
|
132
|
+
.btn { padding: 6px 12px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; font-size: 12px; }
|
|
133
|
+
.btn-primary { background: #0f3460; color: #eaeaea; }
|
|
134
|
+
.btn-primary:hover { background: #1a4a7a; }
|
|
135
|
+
.btn-success { background: #0a4a2a; color: #eaeaea; }
|
|
136
|
+
.btn-success:hover { background: #0f5a3a; }
|
|
137
|
+
.btn-warning { background: #4a3a0a; color: #eaeaea; }
|
|
138
|
+
.btn-warning:hover { background: #5a4a1a; }
|
|
139
|
+
.action-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
|
140
|
+
.detail-section { margin: 16px 0; padding: 12px; background: #16213e; border: 1px solid #4a4a6a; }
|
|
141
|
+
.detail-section h3 { margin: 0 0 8px; font-size: 14px; color: #a0a0ff; }
|
|
142
|
+
.form-group { margin-bottom: 12px; }
|
|
143
|
+
.form-group label { display: block; margin-bottom: 4px; color: #888; }
|
|
144
|
+
.form-group textarea { width: 100%; min-height: 80px; padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; }
|
|
145
|
+
.form-group input { width: 100%; padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; }
|
|
146
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
147
|
+
.back-link:hover { text-decoration: underline; }
|
|
148
|
+
.log-item { padding: 4px 0; border-bottom: 1px solid #2a2a4a; font-size: 12px; }
|
|
149
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
150
|
+
</style>
|
|
151
|
+
</head>
|
|
152
|
+
<body>
|
|
153
|
+
<h1>Palmlist</h1>
|
|
154
|
+
|
|
155
|
+
<div class="filters">
|
|
156
|
+
<label>Project</label>
|
|
157
|
+
<div class="filter-row">
|
|
158
|
+
<a href="${buildFilterUrl(base, { ...filters, project: undefined })}" class="filter-link ${!filters.project ? 'active' : ''}">all</a>
|
|
159
|
+
${projectLinks.join(' ')}
|
|
160
|
+
</div>
|
|
161
|
+
<label>Status</label>
|
|
162
|
+
<div class="filter-row">${statusLinks.join(' ')}</div>
|
|
163
|
+
<label>Owner</label>
|
|
164
|
+
<div class="filter-row">${ownerLinks.join(' ')}</div>
|
|
165
|
+
<label>Task type</label>
|
|
166
|
+
<div class="filter-row">${taskTypeLinks.join(' ')}</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<h2>Projects</h2>
|
|
170
|
+
<div class="project-list">
|
|
171
|
+
${projects.length === 0 ? '<p>No projects.</p>' : projects.map((p) => {
|
|
172
|
+
const tc = p.template_context;
|
|
173
|
+
const rulesDesc = tc.rules.map((r) => `${r.task_type}→${r.default_owner}`).join(', ');
|
|
174
|
+
const workflowContent = tc.workflow_instructions
|
|
175
|
+
? escapeHtml(tc.workflow_instructions)
|
|
176
|
+
: null;
|
|
177
|
+
return `<div class="project-card">
|
|
178
|
+
<strong>${escapeHtml(p.name)}</strong> (${escapeHtml(p.id)}) — ${p.status}
|
|
179
|
+
<div class="template-context">Template v${escapeHtml(tc.version)}: ${escapeHtml(rulesDesc)}</div>
|
|
180
|
+
${workflowContent ? `<div class="workflow-instructions">Workflow: ${workflowContent}</div>` : ''}
|
|
181
|
+
</div>`;
|
|
182
|
+
}).join('')}
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<h2>Kanban (Tasks)</h2>
|
|
186
|
+
<div class="kanban">
|
|
187
|
+
<div class="column">
|
|
188
|
+
<h3>Ready</h3>
|
|
189
|
+
${tasksByStatus.ready.map(renderTaskCard).join('')}
|
|
190
|
+
</div>
|
|
191
|
+
<div class="column">
|
|
192
|
+
<h3>In Progress</h3>
|
|
193
|
+
${tasksByStatus.in_progress.map(renderTaskCard).join('')}
|
|
194
|
+
</div>
|
|
195
|
+
<div class="column">
|
|
196
|
+
<h3>Done</h3>
|
|
197
|
+
${tasksByStatus.done.map(renderTaskCard).join('')}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<h2>Handoffs</h2>
|
|
202
|
+
<div class="handoff-list">
|
|
203
|
+
${handoffs.length === 0 ? '<p>No handoffs.</p>' : handoffs.map((h) => `<div class="handoff-card"><span class="task-id">${escapeHtml(h.id)}</span> ${escapeHtml(h.from_task_id)} → ${h.to_task_id ? escapeHtml(h.to_task_id) : 'audit'}<div class="summary">${escapeHtml(h.summary)}</div></div>`).join('')}
|
|
204
|
+
</div>
|
|
205
|
+
</body>
|
|
206
|
+
</html>`;
|
|
207
|
+
}
|
|
208
|
+
function renderTaskDetailPage(task, projects, error) {
|
|
209
|
+
const handoffsFrom = listHandoffs({ from_task_id: task.id });
|
|
210
|
+
const backUrl = '/';
|
|
211
|
+
let actionButtons = '';
|
|
212
|
+
if (task.status === 'ready' && task.owner === 'agent_worker') {
|
|
213
|
+
actionButtons = `
|
|
214
|
+
<div class="action-row">
|
|
215
|
+
<form method="post" action="/api/task/${task.id}/claim" style="display:inline">
|
|
216
|
+
<button type="submit" class="btn btn-primary">Start</button>
|
|
217
|
+
</form>
|
|
218
|
+
<form method="post" action="/api/task/${task.id}/claim" style="display:inline">
|
|
219
|
+
<button type="submit" class="btn btn-primary">Handoff to AI</button>
|
|
220
|
+
</form>
|
|
221
|
+
</div>`;
|
|
222
|
+
}
|
|
223
|
+
else if (task.status === 'in_progress' && task.owner === 'agent_worker') {
|
|
224
|
+
actionButtons = `
|
|
225
|
+
<div class="action-row">
|
|
226
|
+
<a href="/task/${task.id}/complete" class="btn btn-success">Complete</a>
|
|
227
|
+
<a href="/task/${task.id}/block" class="btn btn-warning">Block (Self-unblock)</a>
|
|
228
|
+
</div>`;
|
|
229
|
+
}
|
|
230
|
+
else if (task.status === 'in_progress' && task.owner === 'human_worker') {
|
|
231
|
+
actionButtons = `
|
|
232
|
+
<div class="action-row">
|
|
233
|
+
<a href="/task/${task.id}/complete" class="btn btn-success">Complete</a>
|
|
234
|
+
</div>`;
|
|
235
|
+
}
|
|
236
|
+
return `<!DOCTYPE html>
|
|
237
|
+
<html lang="en">
|
|
238
|
+
<head>
|
|
239
|
+
<meta charset="UTF-8">
|
|
240
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
241
|
+
<title>${escapeHtml(task.title)} - Palmlist</title>
|
|
242
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
243
|
+
<style>
|
|
244
|
+
* { box-sizing: border-box; }
|
|
245
|
+
body { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; }
|
|
246
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
247
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
248
|
+
.back-link:hover { text-decoration: underline; }
|
|
249
|
+
.detail-section { margin: 16px 0; padding: 12px; background: #16213e; border: 1px solid #4a4a6a; }
|
|
250
|
+
.detail-section h3 { margin: 0 0 8px; font-size: 14px; color: #a0a0ff; }
|
|
251
|
+
.action-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
|
252
|
+
.btn { padding: 6px 12px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; font-size: 12px; text-decoration: none; display: inline-block; }
|
|
253
|
+
.btn-primary { background: #0f3460; color: #eaeaea; }
|
|
254
|
+
.btn-success { background: #0a4a2a; color: #eaeaea; }
|
|
255
|
+
.btn-warning { background: #4a3a0a; color: #eaeaea; }
|
|
256
|
+
.log-item { padding: 4px 0; border-bottom: 1px solid #2a2a4a; font-size: 12px; }
|
|
257
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
258
|
+
.meta { color: #888; font-size: 12px; }
|
|
259
|
+
</style>
|
|
260
|
+
</head>
|
|
261
|
+
<body>
|
|
262
|
+
<a href="${backUrl}" class="back-link">← Back to Kanban</a>
|
|
263
|
+
<h1>${escapeHtml(task.title)}</h1>
|
|
264
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
265
|
+
<div class="meta">${escapeHtml(task.id)} | ${task.status} | ${task.owner} | ${task.task_type}</div>
|
|
266
|
+
${actionButtons}
|
|
267
|
+
|
|
268
|
+
<div class="detail-section">
|
|
269
|
+
<h3>Description</h3>
|
|
270
|
+
<p>${task.description ? escapeHtml(task.description) : '<em>No description</em>'}</p>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div class="detail-section">
|
|
274
|
+
<h3>Acceptance Criteria</h3>
|
|
275
|
+
${task.acceptance_criteria?.length ? '<ul>' + task.acceptance_criteria.map((ac) => `<li>${escapeHtml(ac)}</li>`).join('') + '</ul>' : '<p><em>None</em></p>'}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<div class="detail-section">
|
|
279
|
+
<h3>Dependencies</h3>
|
|
280
|
+
<p>${task.depends_on?.length ? task.depends_on.map((d) => `<a href="/task/${escapeHtml(d)}">${escapeHtml(d)}</a>`).join(', ') : 'None'}</p>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
${task.output ? `<div class="detail-section"><h3>Output</h3><pre>${escapeHtml(task.output)}</pre></div>` : ''}
|
|
284
|
+
|
|
285
|
+
<div class="detail-section">
|
|
286
|
+
<h3>Status History (Logs)</h3>
|
|
287
|
+
${task.logs?.length ? task.logs.map((l) => `<div class="log-item">${escapeHtml(l)}</div>`).join('') : '<p><em>No logs</em></p>'}
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
${handoffsFrom.length ? `<div class="detail-section"><h3>Handoffs from this task</h3>${handoffsFrom.map((h) => `<div class="log-item">→ ${h.to_task_id || 'audit'}: ${escapeHtml(h.summary)}</div>`).join('')}</div>` : ''}
|
|
291
|
+
</body>
|
|
292
|
+
</html>`;
|
|
293
|
+
}
|
|
294
|
+
function renderCompleteForm(task, error) {
|
|
295
|
+
const backUrl = `/task/${task.id}`;
|
|
296
|
+
return `<!DOCTYPE html>
|
|
297
|
+
<html lang="en">
|
|
298
|
+
<head>
|
|
299
|
+
<meta charset="UTF-8">
|
|
300
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
301
|
+
<title>Complete ${escapeHtml(task.id)} - Palmlist</title>
|
|
302
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
303
|
+
<style>
|
|
304
|
+
* { box-sizing: border-box; }
|
|
305
|
+
body { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; }
|
|
306
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
307
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
308
|
+
.form-group { margin-bottom: 12px; }
|
|
309
|
+
.form-group label { display: block; margin-bottom: 4px; color: #888; }
|
|
310
|
+
.form-group textarea { width: 100%; min-height: 100px; padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; }
|
|
311
|
+
.btn { padding: 8px 16px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; background: #0a4a2a; color: #eaeaea; }
|
|
312
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
313
|
+
.hint { color: #888; font-size: 11px; }
|
|
314
|
+
</style>
|
|
315
|
+
</head>
|
|
316
|
+
<body>
|
|
317
|
+
<a href="${backUrl}" class="back-link">← Back to task</a>
|
|
318
|
+
<h1>Complete: ${escapeHtml(task.title)}</h1>
|
|
319
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
320
|
+
<form method="post" action="/api/task/${task.id}/complete">
|
|
321
|
+
<div class="form-group">
|
|
322
|
+
<label>Output</label>
|
|
323
|
+
<textarea name="output" placeholder="What was done..."></textarea>
|
|
324
|
+
</div>
|
|
325
|
+
<div class="form-group">
|
|
326
|
+
<label>Handoff Summary (min 200 chars)</label>
|
|
327
|
+
<textarea name="handoff_summary" placeholder="Summary for handoff..."></textarea>
|
|
328
|
+
<div class="hint">Required for completion. Used when passing context to downstream tasks.</div>
|
|
329
|
+
</div>
|
|
330
|
+
<button type="submit" class="btn">Complete Task</button>
|
|
331
|
+
</form>
|
|
332
|
+
</body>
|
|
333
|
+
</html>`;
|
|
334
|
+
}
|
|
335
|
+
function renderBlockForm(task, projects, error) {
|
|
336
|
+
const backUrl = `/task/${task.id}`;
|
|
337
|
+
const projectOptions = projects.map((p) => `<option value="${escapeHtml(p.id)}" ${p.id === task.project ? 'selected' : ''}>${escapeHtml(p.name)}</option>`).join('');
|
|
338
|
+
return `<!DOCTYPE html>
|
|
339
|
+
<html lang="en">
|
|
340
|
+
<head>
|
|
341
|
+
<meta charset="UTF-8">
|
|
342
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
343
|
+
<title>Block ${escapeHtml(task.id)} - Palmlist</title>
|
|
344
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
345
|
+
<style>
|
|
346
|
+
* { box-sizing: border-box; }
|
|
347
|
+
body { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; }
|
|
348
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
349
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
350
|
+
.form-group { margin-bottom: 12px; }
|
|
351
|
+
.form-group label { display: block; margin-bottom: 4px; color: #888; }
|
|
352
|
+
.form-group input, .form-group select { padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; width: 100%; }
|
|
353
|
+
.btn { padding: 8px 16px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; background: #4a3a0a; color: #eaeaea; }
|
|
354
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
355
|
+
.hint { color: #888; font-size: 11px; margin-top: 4px; }
|
|
356
|
+
</style>
|
|
357
|
+
</head>
|
|
358
|
+
<body>
|
|
359
|
+
<a href="${backUrl}" class="back-link">← Back to task</a>
|
|
360
|
+
<h1>Block (Self-unblock): ${escapeHtml(task.title)}</h1>
|
|
361
|
+
<p>Create a human task first (e.g. credentials), then add it as dependency. The agent task will move to Ready until the human task is done.</p>
|
|
362
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
363
|
+
<form method="post" action="/api/task/${task.id}/block">
|
|
364
|
+
<div class="form-group">
|
|
365
|
+
<label>Option A: Create new human task</label>
|
|
366
|
+
<input type="text" name="new_task_title" placeholder="Title for human task (e.g. Provide API key)">
|
|
367
|
+
<input type="text" name="new_task_description" placeholder="Description">
|
|
368
|
+
<select name="project">${projectOptions}</select>
|
|
369
|
+
<div class="hint">Leave blank to use Option B.</div>
|
|
370
|
+
</div>
|
|
371
|
+
<div class="form-group">
|
|
372
|
+
<label>Option B: Existing human task ID</label>
|
|
373
|
+
<input type="text" name="depends_on" placeholder="task_002">
|
|
374
|
+
<div class="hint">ID of an existing human_worker task to depend on.</div>
|
|
375
|
+
</div>
|
|
376
|
+
<button type="submit" class="btn">Block & Self-unblock</button>
|
|
377
|
+
</form>
|
|
378
|
+
</body>
|
|
379
|
+
</html>`;
|
|
380
|
+
}
|
|
381
|
+
async function parseFormBody(req) {
|
|
382
|
+
const ct = req.headers.get('content-type') || '';
|
|
383
|
+
if (ct.includes('application/x-www-form-urlencoded')) {
|
|
384
|
+
const text = await req.text();
|
|
385
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
386
|
+
}
|
|
387
|
+
return {};
|
|
388
|
+
}
|
|
389
|
+
Bun.serve({
|
|
390
|
+
port: PORT,
|
|
391
|
+
async fetch(req) {
|
|
392
|
+
const url = new URL(req.url);
|
|
393
|
+
const path = url.pathname;
|
|
394
|
+
// API: claim
|
|
395
|
+
if (req.method === 'POST' && path.match(/^\/api\/task\/([^/]+)\/claim$/)) {
|
|
396
|
+
const taskId = path.match(/^\/api\/task\/([^/]+)\/claim$/)[1];
|
|
397
|
+
try {
|
|
398
|
+
claimTask(taskId, 'agent_worker', 'Claimed via Web');
|
|
399
|
+
startTask(taskId, 'agent_worker', 'Started via Web');
|
|
400
|
+
return Response.redirect(`/task/${taskId}`);
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
const task = getTask(taskId);
|
|
404
|
+
const projects = listProjects();
|
|
405
|
+
return new Response(renderTaskDetailPage(task, projects, e.message), {
|
|
406
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
407
|
+
status: 400,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// API: complete
|
|
412
|
+
if (req.method === 'POST' && path.match(/^\/api\/task\/([^/]+)\/complete$/)) {
|
|
413
|
+
const taskId = path.match(/^\/api\/task\/([^/]+)\/complete$/)[1];
|
|
414
|
+
const body = await parseFormBody(req);
|
|
415
|
+
const output = (body.output || '').trim();
|
|
416
|
+
const handoffSummary = (body.handoff_summary || '').trim();
|
|
417
|
+
if (!handoffSummary || handoffSummary.length < 200) {
|
|
418
|
+
const task = getTask(taskId);
|
|
419
|
+
return new Response(renderCompleteForm(task, `Handoff summary must be at least 200 characters (got ${handoffSummary.length})`), {
|
|
420
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
421
|
+
status: 400,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const task = getTask(taskId);
|
|
425
|
+
if (!task)
|
|
426
|
+
return new Response('Task not found', { status: 404 });
|
|
427
|
+
try {
|
|
428
|
+
completeTask(taskId, { output, handoff_summary: handoffSummary, logs_append: 'Completed via Web' }, task.owner);
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
return new Response(renderCompleteForm(task, e.message), {
|
|
432
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
433
|
+
status: 400,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return Response.redirect(`/task/${taskId}`);
|
|
437
|
+
}
|
|
438
|
+
// API: block (self-unblock)
|
|
439
|
+
if (req.method === 'POST' && path.match(/^\/api\/task\/([^/]+)\/block$/)) {
|
|
440
|
+
const taskId = path.match(/^\/api\/task\/([^/]+)\/block$/)[1];
|
|
441
|
+
const body = await parseFormBody(req);
|
|
442
|
+
const projects = listProjects();
|
|
443
|
+
let dependsOn = [];
|
|
444
|
+
if (body.new_task_title) {
|
|
445
|
+
const currentTask = getTask(taskId);
|
|
446
|
+
const projectId = body.project || currentTask?.project || projects[0]?.id;
|
|
447
|
+
if (!projectId) {
|
|
448
|
+
return new Response(renderBlockForm(getTask(taskId), projects, 'No project available'), { headers: { 'Content-Type': 'text/html; charset=utf-8' }, status: 400 });
|
|
449
|
+
}
|
|
450
|
+
const newTask = createTask({
|
|
451
|
+
project: projectId,
|
|
452
|
+
title: body.new_task_title,
|
|
453
|
+
description: body.new_task_description || '',
|
|
454
|
+
acceptance_criteria: [],
|
|
455
|
+
status: 'ready',
|
|
456
|
+
owner: 'human_worker',
|
|
457
|
+
task_type: 'credentials',
|
|
458
|
+
});
|
|
459
|
+
dependsOn = [newTask.id];
|
|
460
|
+
}
|
|
461
|
+
else if (body.depends_on) {
|
|
462
|
+
dependsOn = body.depends_on.split(',').map((s) => s.trim()).filter(Boolean);
|
|
463
|
+
}
|
|
464
|
+
if (dependsOn.length === 0) {
|
|
465
|
+
return new Response(renderBlockForm(getTask(taskId), projects, 'Provide new task title or existing task ID'), { headers: { 'Content-Type': 'text/html; charset=utf-8' }, status: 400 });
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
selfUnblockTask(taskId, { depends_on: dependsOn, logs_append: 'Blocked via Web (self-unblock)' }, 'agent_worker');
|
|
469
|
+
return Response.redirect(`/task/${taskId}`);
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
return new Response(renderBlockForm(getTask(taskId), projects, e.message), {
|
|
473
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
474
|
+
status: 400,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Task detail
|
|
479
|
+
if (path.match(/^\/task\/([^/]+)$/)) {
|
|
480
|
+
const taskId = path.match(/^\/task\/([^/]+)$/)[1];
|
|
481
|
+
const task = getTask(taskId);
|
|
482
|
+
if (!task)
|
|
483
|
+
return new Response('Task not found', { status: 404 });
|
|
484
|
+
const projects = listProjects();
|
|
485
|
+
return new Response(renderTaskDetailPage(task, projects), {
|
|
486
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// Complete form
|
|
490
|
+
if (path.match(/^\/task\/([^/]+)\/complete$/)) {
|
|
491
|
+
const taskId = path.match(/^\/task\/([^/]+)\/complete$/)[1];
|
|
492
|
+
const task = getTask(taskId);
|
|
493
|
+
if (!task)
|
|
494
|
+
return new Response('Task not found', { status: 404 });
|
|
495
|
+
return new Response(renderCompleteForm(task), {
|
|
496
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
// Block form
|
|
500
|
+
if (path.match(/^\/task\/([^/]+)\/block$/)) {
|
|
501
|
+
const taskId = path.match(/^\/task\/([^/]+)\/block$/)[1];
|
|
502
|
+
const task = getTask(taskId);
|
|
503
|
+
if (!task)
|
|
504
|
+
return new Response('Task not found', { status: 404 });
|
|
505
|
+
const projects = listProjects();
|
|
506
|
+
return new Response(renderBlockForm(task, projects), {
|
|
507
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// Home
|
|
511
|
+
if (path === '/') {
|
|
512
|
+
const filters = parseQuery(url);
|
|
513
|
+
const projects = listProjects();
|
|
514
|
+
const tasks = listTasks(filters.project, {
|
|
515
|
+
status: filters.status,
|
|
516
|
+
owner: filters.owner,
|
|
517
|
+
task_type: filters.task_type,
|
|
518
|
+
});
|
|
519
|
+
const handoffs = listHandoffs();
|
|
520
|
+
const html = renderHtml(filters, projects, tasks, handoffs);
|
|
521
|
+
return new Response(html, {
|
|
522
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return new Response('Not found', { status: 404 });
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
console.log(`Palmlist Web: http://localhost:${PORT} (set PORT env to change)`);
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "palmlist-web",
|
|
3
|
+
"version": "0.2.8",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"clean": "rm -rf dist",
|
|
9
|
+
"dev": "bun run --watch src/index.ts",
|
|
10
|
+
"start": "bun run src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"palmlist-core": "workspace:*",
|
|
14
|
+
"palmlist-types": "workspace:*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/bun": "latest",
|
|
18
|
+
"@types/node": "^20.0.0",
|
|
19
|
+
"typescript": "^5.0.0"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palmlist Web - Simple HTTP server for viewing Projects, Tasks, Handoffs
|
|
3
|
+
* Pixel-style UI, URL-persisted filters, task detail page, status transitions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
listProjects,
|
|
8
|
+
listTasks,
|
|
9
|
+
listHandoffs,
|
|
10
|
+
getTask,
|
|
11
|
+
claimTask,
|
|
12
|
+
startTask,
|
|
13
|
+
completeTask,
|
|
14
|
+
selfUnblockTask,
|
|
15
|
+
createTask,
|
|
16
|
+
} from 'palmlist-core';
|
|
17
|
+
import type { Project, Task, Handoff, TaskStatus } from 'palmlist-types';
|
|
18
|
+
import { TASK_STATUSES } from 'palmlist-types';
|
|
19
|
+
|
|
20
|
+
const PORT = Number(process.env.PORT) || 18008;
|
|
21
|
+
|
|
22
|
+
interface FilterParams {
|
|
23
|
+
project?: string;
|
|
24
|
+
status?: TaskStatus[];
|
|
25
|
+
owner?: string;
|
|
26
|
+
task_type?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseQuery(url: URL): FilterParams {
|
|
30
|
+
const params: FilterParams = {};
|
|
31
|
+
const project = url.searchParams.get('project');
|
|
32
|
+
if (project) params.project = project;
|
|
33
|
+
const status = url.searchParams.get('status');
|
|
34
|
+
if (status) {
|
|
35
|
+
const statuses = status.split(',').map((s) => s.trim()).filter(Boolean);
|
|
36
|
+
params.status = statuses.filter((s): s is TaskStatus => TASK_STATUSES.includes(s as TaskStatus));
|
|
37
|
+
}
|
|
38
|
+
const owner = url.searchParams.get('owner');
|
|
39
|
+
if (owner) params.owner = owner;
|
|
40
|
+
const taskType = url.searchParams.get('task_type');
|
|
41
|
+
if (taskType) params.task_type = taskType;
|
|
42
|
+
return params;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildFilterUrl(base: string, overrides: Partial<FilterParams>): string {
|
|
46
|
+
const params = new URLSearchParams();
|
|
47
|
+
if (overrides.project) params.set('project', overrides.project);
|
|
48
|
+
if (overrides.status?.length) params.set('status', overrides.status.join(','));
|
|
49
|
+
if (overrides.owner) params.set('owner', overrides.owner);
|
|
50
|
+
if (overrides.task_type) params.set('task_type', overrides.task_type);
|
|
51
|
+
const qs = params.toString();
|
|
52
|
+
return qs ? `${base}?${qs}` : base;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeHtml(s: string): string {
|
|
56
|
+
return s
|
|
57
|
+
.replace(/&/g, '&')
|
|
58
|
+
.replace(/</g, '<')
|
|
59
|
+
.replace(/>/g, '>')
|
|
60
|
+
.replace(/"/g, '"');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function taskCardTooltip(t: Task): string {
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
if (t.description) parts.push(escapeHtml(t.description));
|
|
66
|
+
if (t.acceptance_criteria?.length) {
|
|
67
|
+
parts.push('AC: ' + t.acceptance_criteria.map((ac) => escapeHtml(ac)).join('; '));
|
|
68
|
+
}
|
|
69
|
+
return parts.length ? parts.join('\n\n') : 'No description';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderTaskCard(t: Task): string {
|
|
73
|
+
const tooltip = taskCardTooltip(t);
|
|
74
|
+
const hasTooltip = tooltip !== 'No description';
|
|
75
|
+
const tooltipAttr = hasTooltip ? ` title="${tooltip.replace(/"/g, '"').replace(/\n/g, ' ')}"` : '';
|
|
76
|
+
const backUrl = '/';
|
|
77
|
+
return `<div class="task-card"${tooltipAttr}>
|
|
78
|
+
<a href="/task/${escapeHtml(t.id)}" class="task-link"><span class="task-id">${escapeHtml(t.id)}</span> [${t.owner}] ${escapeHtml(t.title)}</a>
|
|
79
|
+
${hasTooltip ? '<div class="task-preview">' + escapeHtml(t.description || '') + '</div>' : ''}
|
|
80
|
+
</div>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderHtml(filters: FilterParams, projects: Project[], tasks: Task[], handoffs: Handoff[]): string {
|
|
84
|
+
const base = '/';
|
|
85
|
+
const projectLinks = projects.map(
|
|
86
|
+
(p) =>
|
|
87
|
+
`<a href="${buildFilterUrl(base, { ...filters, project: p.id })}" class="filter-link ${filters.project === p.id ? 'active' : ''}">${escapeHtml(p.name)}</a>`
|
|
88
|
+
);
|
|
89
|
+
const statusOpts: { v: TaskStatus[] | undefined; l: string }[] = [
|
|
90
|
+
{ v: undefined, l: 'all' },
|
|
91
|
+
{ v: ['ready'], l: 'ready' },
|
|
92
|
+
{ v: ['in_progress'], l: 'in_progress' },
|
|
93
|
+
{ v: ['done'], l: 'done' },
|
|
94
|
+
{ v: ['ready', 'in_progress'], l: 'unfinished' },
|
|
95
|
+
];
|
|
96
|
+
const statusLinks = statusOpts.map(
|
|
97
|
+
(s) =>
|
|
98
|
+
`<a href="${buildFilterUrl(base, { ...filters, status: s.v })}" class="filter-link ${JSON.stringify(filters.status || []) === JSON.stringify(s.v || []) ? 'active' : ''}">${s.l}</a>`
|
|
99
|
+
);
|
|
100
|
+
const ownerLinks = [
|
|
101
|
+
{ v: undefined, l: 'all' },
|
|
102
|
+
{ v: 'agent_worker', l: 'agent' },
|
|
103
|
+
{ v: 'human_worker', l: 'human' },
|
|
104
|
+
].map(
|
|
105
|
+
(o) =>
|
|
106
|
+
`<a href="${buildFilterUrl(base, { ...filters, owner: o.v })}" class="filter-link ${filters.owner === o.v ? 'active' : ''}">${o.l}</a>`
|
|
107
|
+
);
|
|
108
|
+
const taskTypeLinks = [
|
|
109
|
+
undefined,
|
|
110
|
+
'implementation',
|
|
111
|
+
'testing',
|
|
112
|
+
'review',
|
|
113
|
+
'credentials',
|
|
114
|
+
'deploy',
|
|
115
|
+
'docs',
|
|
116
|
+
'custom',
|
|
117
|
+
].map(
|
|
118
|
+
(t) =>
|
|
119
|
+
`<a href="${buildFilterUrl(base, { ...filters, task_type: t })}" class="filter-link ${(filters.task_type || '') === (t || '') ? 'active' : ''}">${t || 'all'}</a>`
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const tasksByStatus = {
|
|
123
|
+
ready: tasks.filter((t) => t.status === 'ready'),
|
|
124
|
+
in_progress: tasks.filter((t) => t.status === 'in_progress'),
|
|
125
|
+
done: tasks.filter((t) => t.status === 'done'),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return `<!DOCTYPE html>
|
|
129
|
+
<html lang="en">
|
|
130
|
+
<head>
|
|
131
|
+
<meta charset="UTF-8">
|
|
132
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
133
|
+
<title>Palmlist</title>
|
|
134
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
135
|
+
<style>
|
|
136
|
+
* { box-sizing: border-box; }
|
|
137
|
+
body { font-family: "JetBrains Mono", ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; -webkit-font-smoothing: antialiased; }
|
|
138
|
+
h1 { font-size: 20px; margin: 0 0 16px; border-bottom: 2px solid #4a4a6a; padding-bottom: 8px; }
|
|
139
|
+
h2 { font-size: 16px; margin: 24px 0 12px; color: #a0a0ff; }
|
|
140
|
+
.filters { margin-bottom: 16px; padding: 12px; background: #16213e; border: 1px solid #4a4a6a; }
|
|
141
|
+
.filters label { display: block; font-size: 13px; color: #888; margin-bottom: 4px; }
|
|
142
|
+
.filter-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
|
|
143
|
+
.filter-row:last-child { margin-bottom: 0; }
|
|
144
|
+
.filter-link { padding: 6px 10px; background: #0f3460; color: #eaeaea; text-decoration: none; border: 2px solid #4a4a6a; }
|
|
145
|
+
.filter-link:hover { background: #1a4a7a; }
|
|
146
|
+
.filter-link.active { background: #4a4a6a; border-color: #a0a0ff; }
|
|
147
|
+
.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
|
148
|
+
.column { background: #16213e; border: 1px solid #4a4a6a; padding: 12px; min-height: 120px; }
|
|
149
|
+
.column h3 { font-size: 14px; margin: 0 0 12px; color: #a0a0ff; }
|
|
150
|
+
.task-card { background: #0f3460; border: 1px solid #4a4a6a; padding: 8px; margin-bottom: 8px; font-size: 13px; cursor: pointer; }
|
|
151
|
+
.task-card:last-child { margin-bottom: 0; }
|
|
152
|
+
.task-card:hover { border-color: #6a6a8a; }
|
|
153
|
+
.task-link { color: #eaeaea; text-decoration: none; }
|
|
154
|
+
.task-link:hover { text-decoration: underline; }
|
|
155
|
+
.task-id { color: #888; font-size: 12px; }
|
|
156
|
+
.task-preview { font-size: 11px; color: #888; margin-top: 4px; max-height: 2.4em; overflow: hidden; }
|
|
157
|
+
.project-card { background: #16213e; border: 1px solid #4a4a6a; padding: 12px; margin-bottom: 8px; }
|
|
158
|
+
.template-context { margin-top: 8px; font-size: 12px; color: #888; }
|
|
159
|
+
.handoff-card { background: #16213e; border: 1px solid #4a4a6a; padding: 12px; margin-bottom: 8px; font-size: 13px; }
|
|
160
|
+
.handoff-card .summary { color: #b0b0b0; margin-top: 4px; }
|
|
161
|
+
table { width: 100%; border-collapse: collapse; }
|
|
162
|
+
th, td { padding: 8px; text-align: left; border: 1px solid #4a4a6a; }
|
|
163
|
+
th { background: #16213e; color: #a0a0ff; }
|
|
164
|
+
.btn { padding: 6px 12px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; font-size: 12px; }
|
|
165
|
+
.btn-primary { background: #0f3460; color: #eaeaea; }
|
|
166
|
+
.btn-primary:hover { background: #1a4a7a; }
|
|
167
|
+
.btn-success { background: #0a4a2a; color: #eaeaea; }
|
|
168
|
+
.btn-success:hover { background: #0f5a3a; }
|
|
169
|
+
.btn-warning { background: #4a3a0a; color: #eaeaea; }
|
|
170
|
+
.btn-warning:hover { background: #5a4a1a; }
|
|
171
|
+
.action-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
|
172
|
+
.detail-section { margin: 16px 0; padding: 12px; background: #16213e; border: 1px solid #4a4a6a; }
|
|
173
|
+
.detail-section h3 { margin: 0 0 8px; font-size: 14px; color: #a0a0ff; }
|
|
174
|
+
.form-group { margin-bottom: 12px; }
|
|
175
|
+
.form-group label { display: block; margin-bottom: 4px; color: #888; }
|
|
176
|
+
.form-group textarea { width: 100%; min-height: 80px; padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; }
|
|
177
|
+
.form-group input { width: 100%; padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; }
|
|
178
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
179
|
+
.back-link:hover { text-decoration: underline; }
|
|
180
|
+
.log-item { padding: 4px 0; border-bottom: 1px solid #2a2a4a; font-size: 12px; }
|
|
181
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<h1>Palmlist</h1>
|
|
186
|
+
|
|
187
|
+
<div class="filters">
|
|
188
|
+
<label>Project</label>
|
|
189
|
+
<div class="filter-row">
|
|
190
|
+
<a href="${buildFilterUrl(base, { ...filters, project: undefined })}" class="filter-link ${!filters.project ? 'active' : ''}">all</a>
|
|
191
|
+
${projectLinks.join(' ')}
|
|
192
|
+
</div>
|
|
193
|
+
<label>Status</label>
|
|
194
|
+
<div class="filter-row">${statusLinks.join(' ')}</div>
|
|
195
|
+
<label>Owner</label>
|
|
196
|
+
<div class="filter-row">${ownerLinks.join(' ')}</div>
|
|
197
|
+
<label>Task type</label>
|
|
198
|
+
<div class="filter-row">${taskTypeLinks.join(' ')}</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<h2>Projects</h2>
|
|
202
|
+
<div class="project-list">
|
|
203
|
+
${projects.length === 0 ? '<p>No projects.</p>' : projects.map((p) => {
|
|
204
|
+
const tc = p.template_context;
|
|
205
|
+
const rulesDesc = tc.rules.map((r) => `${r.task_type}→${r.default_owner}`).join(', ');
|
|
206
|
+
const workflowContent = tc.workflow_instructions
|
|
207
|
+
? escapeHtml(tc.workflow_instructions)
|
|
208
|
+
: null;
|
|
209
|
+
return `<div class="project-card">
|
|
210
|
+
<strong>${escapeHtml(p.name)}</strong> (${escapeHtml(p.id)}) — ${p.status}
|
|
211
|
+
<div class="template-context">Template v${escapeHtml(tc.version)}: ${escapeHtml(rulesDesc)}</div>
|
|
212
|
+
${workflowContent ? `<div class="workflow-instructions">Workflow: ${workflowContent}</div>` : ''}
|
|
213
|
+
</div>`;
|
|
214
|
+
}).join('')}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<h2>Kanban (Tasks)</h2>
|
|
218
|
+
<div class="kanban">
|
|
219
|
+
<div class="column">
|
|
220
|
+
<h3>Ready</h3>
|
|
221
|
+
${tasksByStatus.ready.map(renderTaskCard).join('')}
|
|
222
|
+
</div>
|
|
223
|
+
<div class="column">
|
|
224
|
+
<h3>In Progress</h3>
|
|
225
|
+
${tasksByStatus.in_progress.map(renderTaskCard).join('')}
|
|
226
|
+
</div>
|
|
227
|
+
<div class="column">
|
|
228
|
+
<h3>Done</h3>
|
|
229
|
+
${tasksByStatus.done.map(renderTaskCard).join('')}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<h2>Handoffs</h2>
|
|
234
|
+
<div class="handoff-list">
|
|
235
|
+
${handoffs.length === 0 ? '<p>No handoffs.</p>' : handoffs.map((h) => `<div class="handoff-card"><span class="task-id">${escapeHtml(h.id)}</span> ${escapeHtml(h.from_task_id)} → ${h.to_task_id ? escapeHtml(h.to_task_id) : 'audit'}<div class="summary">${escapeHtml(h.summary)}</div></div>`).join('')}
|
|
236
|
+
</div>
|
|
237
|
+
</body>
|
|
238
|
+
</html>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderTaskDetailPage(task: Task, projects: Project[], error?: string): string {
|
|
242
|
+
const handoffsFrom = listHandoffs({ from_task_id: task.id });
|
|
243
|
+
const backUrl = '/';
|
|
244
|
+
|
|
245
|
+
let actionButtons = '';
|
|
246
|
+
if (task.status === 'ready' && task.owner === 'agent_worker') {
|
|
247
|
+
actionButtons = `
|
|
248
|
+
<div class="action-row">
|
|
249
|
+
<form method="post" action="/api/task/${task.id}/claim" style="display:inline">
|
|
250
|
+
<button type="submit" class="btn btn-primary">Start</button>
|
|
251
|
+
</form>
|
|
252
|
+
<form method="post" action="/api/task/${task.id}/claim" style="display:inline">
|
|
253
|
+
<button type="submit" class="btn btn-primary">Handoff to AI</button>
|
|
254
|
+
</form>
|
|
255
|
+
</div>`;
|
|
256
|
+
} else if (task.status === 'in_progress' && task.owner === 'agent_worker') {
|
|
257
|
+
actionButtons = `
|
|
258
|
+
<div class="action-row">
|
|
259
|
+
<a href="/task/${task.id}/complete" class="btn btn-success">Complete</a>
|
|
260
|
+
<a href="/task/${task.id}/block" class="btn btn-warning">Block (Self-unblock)</a>
|
|
261
|
+
</div>`;
|
|
262
|
+
} else if (task.status === 'in_progress' && task.owner === 'human_worker') {
|
|
263
|
+
actionButtons = `
|
|
264
|
+
<div class="action-row">
|
|
265
|
+
<a href="/task/${task.id}/complete" class="btn btn-success">Complete</a>
|
|
266
|
+
</div>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return `<!DOCTYPE html>
|
|
270
|
+
<html lang="en">
|
|
271
|
+
<head>
|
|
272
|
+
<meta charset="UTF-8">
|
|
273
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
274
|
+
<title>${escapeHtml(task.title)} - Palmlist</title>
|
|
275
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
276
|
+
<style>
|
|
277
|
+
* { box-sizing: border-box; }
|
|
278
|
+
body { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; }
|
|
279
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
280
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
281
|
+
.back-link:hover { text-decoration: underline; }
|
|
282
|
+
.detail-section { margin: 16px 0; padding: 12px; background: #16213e; border: 1px solid #4a4a6a; }
|
|
283
|
+
.detail-section h3 { margin: 0 0 8px; font-size: 14px; color: #a0a0ff; }
|
|
284
|
+
.action-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
|
285
|
+
.btn { padding: 6px 12px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; font-size: 12px; text-decoration: none; display: inline-block; }
|
|
286
|
+
.btn-primary { background: #0f3460; color: #eaeaea; }
|
|
287
|
+
.btn-success { background: #0a4a2a; color: #eaeaea; }
|
|
288
|
+
.btn-warning { background: #4a3a0a; color: #eaeaea; }
|
|
289
|
+
.log-item { padding: 4px 0; border-bottom: 1px solid #2a2a4a; font-size: 12px; }
|
|
290
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
291
|
+
.meta { color: #888; font-size: 12px; }
|
|
292
|
+
</style>
|
|
293
|
+
</head>
|
|
294
|
+
<body>
|
|
295
|
+
<a href="${backUrl}" class="back-link">← Back to Kanban</a>
|
|
296
|
+
<h1>${escapeHtml(task.title)}</h1>
|
|
297
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
298
|
+
<div class="meta">${escapeHtml(task.id)} | ${task.status} | ${task.owner} | ${task.task_type}</div>
|
|
299
|
+
${actionButtons}
|
|
300
|
+
|
|
301
|
+
<div class="detail-section">
|
|
302
|
+
<h3>Description</h3>
|
|
303
|
+
<p>${task.description ? escapeHtml(task.description) : '<em>No description</em>'}</p>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div class="detail-section">
|
|
307
|
+
<h3>Acceptance Criteria</h3>
|
|
308
|
+
${task.acceptance_criteria?.length ? '<ul>' + task.acceptance_criteria.map((ac) => `<li>${escapeHtml(ac)}</li>`).join('') + '</ul>' : '<p><em>None</em></p>'}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<div class="detail-section">
|
|
312
|
+
<h3>Dependencies</h3>
|
|
313
|
+
<p>${task.depends_on?.length ? task.depends_on.map((d) => `<a href="/task/${escapeHtml(d)}">${escapeHtml(d)}</a>`).join(', ') : 'None'}</p>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
${task.output ? `<div class="detail-section"><h3>Output</h3><pre>${escapeHtml(task.output)}</pre></div>` : ''}
|
|
317
|
+
|
|
318
|
+
<div class="detail-section">
|
|
319
|
+
<h3>Status History (Logs)</h3>
|
|
320
|
+
${task.logs?.length ? task.logs.map((l) => `<div class="log-item">${escapeHtml(l)}</div>`).join('') : '<p><em>No logs</em></p>'}
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
${handoffsFrom.length ? `<div class="detail-section"><h3>Handoffs from this task</h3>${handoffsFrom.map((h) => `<div class="log-item">→ ${h.to_task_id || 'audit'}: ${escapeHtml(h.summary)}</div>`).join('')}</div>` : ''}
|
|
324
|
+
</body>
|
|
325
|
+
</html>`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function renderCompleteForm(task: Task, error?: string): string {
|
|
329
|
+
const backUrl = `/task/${task.id}`;
|
|
330
|
+
return `<!DOCTYPE html>
|
|
331
|
+
<html lang="en">
|
|
332
|
+
<head>
|
|
333
|
+
<meta charset="UTF-8">
|
|
334
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
335
|
+
<title>Complete ${escapeHtml(task.id)} - Palmlist</title>
|
|
336
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
337
|
+
<style>
|
|
338
|
+
* { box-sizing: border-box; }
|
|
339
|
+
body { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; }
|
|
340
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
341
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
342
|
+
.form-group { margin-bottom: 12px; }
|
|
343
|
+
.form-group label { display: block; margin-bottom: 4px; color: #888; }
|
|
344
|
+
.form-group textarea { width: 100%; min-height: 100px; padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; }
|
|
345
|
+
.btn { padding: 8px 16px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; background: #0a4a2a; color: #eaeaea; }
|
|
346
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
347
|
+
.hint { color: #888; font-size: 11px; }
|
|
348
|
+
</style>
|
|
349
|
+
</head>
|
|
350
|
+
<body>
|
|
351
|
+
<a href="${backUrl}" class="back-link">← Back to task</a>
|
|
352
|
+
<h1>Complete: ${escapeHtml(task.title)}</h1>
|
|
353
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
354
|
+
<form method="post" action="/api/task/${task.id}/complete">
|
|
355
|
+
<div class="form-group">
|
|
356
|
+
<label>Output</label>
|
|
357
|
+
<textarea name="output" placeholder="What was done..."></textarea>
|
|
358
|
+
</div>
|
|
359
|
+
<div class="form-group">
|
|
360
|
+
<label>Handoff Summary (min 200 chars)</label>
|
|
361
|
+
<textarea name="handoff_summary" placeholder="Summary for handoff..."></textarea>
|
|
362
|
+
<div class="hint">Required for completion. Used when passing context to downstream tasks.</div>
|
|
363
|
+
</div>
|
|
364
|
+
<button type="submit" class="btn">Complete Task</button>
|
|
365
|
+
</form>
|
|
366
|
+
</body>
|
|
367
|
+
</html>`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function renderBlockForm(task: Task, projects: Project[], error?: string): string {
|
|
371
|
+
const backUrl = `/task/${task.id}`;
|
|
372
|
+
const projectOptions = projects.map(
|
|
373
|
+
(p) => `<option value="${escapeHtml(p.id)}" ${p.id === task.project ? 'selected' : ''}>${escapeHtml(p.name)}</option>`
|
|
374
|
+
).join('');
|
|
375
|
+
return `<!DOCTYPE html>
|
|
376
|
+
<html lang="en">
|
|
377
|
+
<head>
|
|
378
|
+
<meta charset="UTF-8">
|
|
379
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
380
|
+
<title>Block ${escapeHtml(task.id)} - Palmlist</title>
|
|
381
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
382
|
+
<style>
|
|
383
|
+
* { box-sizing: border-box; }
|
|
384
|
+
body { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 13px; line-height: 1.5; margin: 0; padding: 16px; background: #1a1a2e; color: #eaeaea; }
|
|
385
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
386
|
+
.back-link { color: #a0a0ff; text-decoration: none; margin-bottom: 16px; display: inline-block; }
|
|
387
|
+
.form-group { margin-bottom: 12px; }
|
|
388
|
+
.form-group label { display: block; margin-bottom: 4px; color: #888; }
|
|
389
|
+
.form-group input, .form-group select { padding: 8px; background: #0f3460; border: 1px solid #4a4a6a; color: #eaeaea; font-family: inherit; width: 100%; }
|
|
390
|
+
.btn { padding: 8px 16px; border: 2px solid #4a4a6a; cursor: pointer; font-family: inherit; background: #4a3a0a; color: #eaeaea; }
|
|
391
|
+
.error { color: #ff6b6b; margin: 8px 0; }
|
|
392
|
+
.hint { color: #888; font-size: 11px; margin-top: 4px; }
|
|
393
|
+
</style>
|
|
394
|
+
</head>
|
|
395
|
+
<body>
|
|
396
|
+
<a href="${backUrl}" class="back-link">← Back to task</a>
|
|
397
|
+
<h1>Block (Self-unblock): ${escapeHtml(task.title)}</h1>
|
|
398
|
+
<p>Create a human task first (e.g. credentials), then add it as dependency. The agent task will move to Ready until the human task is done.</p>
|
|
399
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
400
|
+
<form method="post" action="/api/task/${task.id}/block">
|
|
401
|
+
<div class="form-group">
|
|
402
|
+
<label>Option A: Create new human task</label>
|
|
403
|
+
<input type="text" name="new_task_title" placeholder="Title for human task (e.g. Provide API key)">
|
|
404
|
+
<input type="text" name="new_task_description" placeholder="Description">
|
|
405
|
+
<select name="project">${projectOptions}</select>
|
|
406
|
+
<div class="hint">Leave blank to use Option B.</div>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="form-group">
|
|
409
|
+
<label>Option B: Existing human task ID</label>
|
|
410
|
+
<input type="text" name="depends_on" placeholder="task_002">
|
|
411
|
+
<div class="hint">ID of an existing human_worker task to depend on.</div>
|
|
412
|
+
</div>
|
|
413
|
+
<button type="submit" class="btn">Block & Self-unblock</button>
|
|
414
|
+
</form>
|
|
415
|
+
</body>
|
|
416
|
+
</html>`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function parseFormBody(req: Request): Promise<Record<string, string>> {
|
|
420
|
+
const ct = req.headers.get('content-type') || '';
|
|
421
|
+
if (ct.includes('application/x-www-form-urlencoded')) {
|
|
422
|
+
const text = await req.text();
|
|
423
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
424
|
+
}
|
|
425
|
+
return {};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
Bun.serve({
|
|
429
|
+
port: PORT,
|
|
430
|
+
async fetch(req) {
|
|
431
|
+
const url = new URL(req.url);
|
|
432
|
+
const path = url.pathname;
|
|
433
|
+
|
|
434
|
+
// API: claim
|
|
435
|
+
if (req.method === 'POST' && path.match(/^\/api\/task\/([^/]+)\/claim$/)) {
|
|
436
|
+
const taskId = path.match(/^\/api\/task\/([^/]+)\/claim$/)![1];
|
|
437
|
+
try {
|
|
438
|
+
claimTask(taskId, 'agent_worker', 'Claimed via Web');
|
|
439
|
+
startTask(taskId, 'agent_worker', 'Started via Web');
|
|
440
|
+
return Response.redirect(`/task/${taskId}`);
|
|
441
|
+
} catch (e) {
|
|
442
|
+
const task = getTask(taskId);
|
|
443
|
+
const projects = listProjects();
|
|
444
|
+
return new Response(renderTaskDetailPage(task!, projects, (e as Error).message), {
|
|
445
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
446
|
+
status: 400,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// API: complete
|
|
452
|
+
if (req.method === 'POST' && path.match(/^\/api\/task\/([^/]+)\/complete$/)) {
|
|
453
|
+
const taskId = path.match(/^\/api\/task\/([^/]+)\/complete$/)![1];
|
|
454
|
+
const body = await parseFormBody(req);
|
|
455
|
+
const output = (body.output || '').trim();
|
|
456
|
+
const handoffSummary = (body.handoff_summary || '').trim();
|
|
457
|
+
if (!handoffSummary || handoffSummary.length < 200) {
|
|
458
|
+
const task = getTask(taskId);
|
|
459
|
+
return new Response(renderCompleteForm(task!, `Handoff summary must be at least 200 characters (got ${handoffSummary.length})`), {
|
|
460
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
461
|
+
status: 400,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const task = getTask(taskId);
|
|
465
|
+
if (!task) return new Response('Task not found', { status: 404 });
|
|
466
|
+
try {
|
|
467
|
+
completeTask(taskId, { output, handoff_summary: handoffSummary, logs_append: 'Completed via Web' }, task.owner);
|
|
468
|
+
} catch (e) {
|
|
469
|
+
return new Response(renderCompleteForm(task, (e as Error).message), {
|
|
470
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
471
|
+
status: 400,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return Response.redirect(`/task/${taskId}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// API: block (self-unblock)
|
|
478
|
+
if (req.method === 'POST' && path.match(/^\/api\/task\/([^/]+)\/block$/)) {
|
|
479
|
+
const taskId = path.match(/^\/api\/task\/([^/]+)\/block$/)![1];
|
|
480
|
+
const body = await parseFormBody(req);
|
|
481
|
+
const projects = listProjects();
|
|
482
|
+
let dependsOn: string[] = [];
|
|
483
|
+
if (body.new_task_title) {
|
|
484
|
+
const currentTask = getTask(taskId);
|
|
485
|
+
const projectId = body.project || currentTask?.project || projects[0]?.id;
|
|
486
|
+
if (!projectId) {
|
|
487
|
+
return new Response(renderBlockForm(getTask(taskId)!, projects, 'No project available'), { headers: { 'Content-Type': 'text/html; charset=utf-8' }, status: 400 });
|
|
488
|
+
}
|
|
489
|
+
const newTask = createTask({
|
|
490
|
+
project: projectId,
|
|
491
|
+
title: body.new_task_title,
|
|
492
|
+
description: body.new_task_description || '',
|
|
493
|
+
acceptance_criteria: [],
|
|
494
|
+
status: 'ready',
|
|
495
|
+
owner: 'human_worker',
|
|
496
|
+
task_type: 'credentials',
|
|
497
|
+
});
|
|
498
|
+
dependsOn = [newTask.id];
|
|
499
|
+
} else if (body.depends_on) {
|
|
500
|
+
dependsOn = body.depends_on.split(',').map((s) => s.trim()).filter(Boolean);
|
|
501
|
+
}
|
|
502
|
+
if (dependsOn.length === 0) {
|
|
503
|
+
return new Response(renderBlockForm(getTask(taskId)!, projects, 'Provide new task title or existing task ID'), { headers: { 'Content-Type': 'text/html; charset=utf-8' }, status: 400 });
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
selfUnblockTask(taskId, { depends_on: dependsOn, logs_append: 'Blocked via Web (self-unblock)' }, 'agent_worker');
|
|
507
|
+
return Response.redirect(`/task/${taskId}`);
|
|
508
|
+
} catch (e) {
|
|
509
|
+
return new Response(renderBlockForm(getTask(taskId)!, projects, (e as Error).message), {
|
|
510
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
511
|
+
status: 400,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Task detail
|
|
517
|
+
if (path.match(/^\/task\/([^/]+)$/)) {
|
|
518
|
+
const taskId = path.match(/^\/task\/([^/]+)$/)![1];
|
|
519
|
+
const task = getTask(taskId);
|
|
520
|
+
if (!task) return new Response('Task not found', { status: 404 });
|
|
521
|
+
const projects = listProjects();
|
|
522
|
+
return new Response(renderTaskDetailPage(task, projects), {
|
|
523
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Complete form
|
|
528
|
+
if (path.match(/^\/task\/([^/]+)\/complete$/)) {
|
|
529
|
+
const taskId = path.match(/^\/task\/([^/]+)\/complete$/)![1];
|
|
530
|
+
const task = getTask(taskId);
|
|
531
|
+
if (!task) return new Response('Task not found', { status: 404 });
|
|
532
|
+
return new Response(renderCompleteForm(task), {
|
|
533
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Block form
|
|
538
|
+
if (path.match(/^\/task\/([^/]+)\/block$/)) {
|
|
539
|
+
const taskId = path.match(/^\/task\/([^/]+)\/block$/)![1];
|
|
540
|
+
const task = getTask(taskId);
|
|
541
|
+
if (!task) return new Response('Task not found', { status: 404 });
|
|
542
|
+
const projects = listProjects();
|
|
543
|
+
return new Response(renderBlockForm(task, projects), {
|
|
544
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Home
|
|
549
|
+
if (path === '/') {
|
|
550
|
+
const filters = parseQuery(url);
|
|
551
|
+
const projects = listProjects();
|
|
552
|
+
const tasks = listTasks(filters.project, {
|
|
553
|
+
status: filters.status,
|
|
554
|
+
owner: filters.owner,
|
|
555
|
+
task_type: filters.task_type,
|
|
556
|
+
});
|
|
557
|
+
const handoffs = listHandoffs();
|
|
558
|
+
const html = renderHtml(filters, projects, tasks, handoffs);
|
|
559
|
+
return new Response(html, {
|
|
560
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return new Response('Not found', { status: 404 });
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
console.log(`Palmlist Web: http://localhost:${PORT} (set PORT env to change)`);
|