fairchild 0.0.4__tar.gz → 0.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {fairchild-0.0.4 → fairchild-0.0.6}/PKG-INFO +1 -1
  2. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/__init__.py +0 -2
  3. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/cli.py +4 -12
  4. fairchild-0.0.6/fairchild/db/migrations/005_add_worker_tasks.sql +3 -0
  5. fairchild-0.0.6/fairchild/db/migrations/006_rename_recorded_to_result.sql +2 -0
  6. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/task.py +48 -8
  7. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/templates/dashboard.html +32 -33
  8. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/templates/job.html +227 -10
  9. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/ui.py +68 -63
  10. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/worker.py +30 -22
  11. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/PKG-INFO +1 -1
  12. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/SOURCES.txt +2 -2
  13. {fairchild-0.0.4 → fairchild-0.0.6}/pyproject.toml +1 -1
  14. {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_cli.py +49 -35
  15. {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_task.py +17 -22
  16. {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_web_ui.py +41 -15
  17. fairchild-0.0.4/fairchild/record.py +0 -22
  18. fairchild-0.0.4/tests/test_record.py +0 -40
  19. {fairchild-0.0.4 → fairchild-0.0.6}/LICENSE +0 -0
  20. {fairchild-0.0.4 → fairchild-0.0.6}/README.md +0 -0
  21. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/context.py +0 -0
  22. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/__init__.py +0 -0
  23. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/001_initial.sql +0 -0
  24. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/002_add_parent_id.sql +0 -0
  25. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/003_remove_workflows.sql +0 -0
  26. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations/004_add_workers.sql +0 -0
  27. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/db/migrations.py +0 -0
  28. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/fairchild.py +0 -0
  29. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/future.py +0 -0
  30. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild/job.py +0 -0
  31. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/dependency_links.txt +0 -0
  32. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/entry_points.txt +0 -0
  33. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/requires.txt +0 -0
  34. {fairchild-0.0.4 → fairchild-0.0.6}/fairchild.egg-info/top_level.txt +0 -0
  35. {fairchild-0.0.4 → fairchild-0.0.6}/setup.cfg +0 -0
  36. {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_integration.py +0 -0
  37. {fairchild-0.0.4 → fairchild-0.0.6}/tests/test_job.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairchild
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Workflow scheduling with PostgreSQL
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -1,11 +1,9 @@
1
1
  from fairchild.task import task
2
2
  from fairchild.job import Job
3
- from fairchild.record import Record
4
3
  from fairchild.fairchild import Fairchild
5
4
 
6
5
  __all__ = [
7
6
  "task",
8
7
  "Job",
9
- "Record",
10
8
  "Fairchild",
11
9
  ]
@@ -162,19 +162,11 @@ async def _run_workers(queue_config: dict[str, int]):
162
162
  @cli.command()
163
163
  @click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
164
164
  @click.option("--port", "-p", default=4000, help="Port to bind to")
165
- @click.option(
166
- "--import",
167
- "-i",
168
- "imports",
169
- multiple=True,
170
- help='Module to import for task registration, e.g., "myapp.tasks"',
171
- )
172
- def ui(host: str, port: int, imports: tuple[str, ...]):
173
- """Start the web UI dashboard."""
174
- # Import task modules
175
- for module_path in imports:
176
- import_module(module_path)
165
+ def ui(host: str, port: int):
166
+ """Start the web UI dashboard.
177
167
 
168
+ The UI reads task definitions from active workers, so no --import is needed.
169
+ """
178
170
  click.echo(f"Starting Fairchild UI at http://{host}:{port}")
179
171
  asyncio.run(_run_ui(host, port))
180
172
 
@@ -0,0 +1,3 @@
1
+ -- Add tasks column to workers table
2
+ -- Workers publish their registered tasks (with schemas) on startup
3
+ ALTER TABLE fairchild_workers ADD COLUMN IF NOT EXISTS tasks JSONB DEFAULT '[]';
@@ -0,0 +1,2 @@
1
+ -- Rename recorded column to result
2
+ ALTER TABLE fairchild_jobs RENAME COLUMN recorded TO result;
@@ -15,6 +15,52 @@ def get_task(name: str) -> "Task":
15
15
  return _task_registry[name]
16
16
 
17
17
 
18
+ def get_task_schemas() -> list[dict]:
19
+ """Get schemas for all registered tasks.
20
+
21
+ Returns a list of task info dicts suitable for storing in the database.
22
+ """
23
+ tasks = []
24
+ for name, task in sorted(_task_registry.items()):
25
+ sig = inspect.signature(task.fn)
26
+ params = []
27
+ for param_name, param in sig.parameters.items():
28
+ if param_name in ("job", "workflow"):
29
+ continue
30
+
31
+ param_info = {"name": param_name}
32
+
33
+ if param.annotation != inspect.Parameter.empty:
34
+ try:
35
+ param_info["type"] = param.annotation.__name__
36
+ except AttributeError:
37
+ param_info["type"] = str(param.annotation)
38
+
39
+ if param.default != inspect.Parameter.empty:
40
+ param_info["default"] = param.default
41
+ param_info["required"] = False
42
+ else:
43
+ param_info["required"] = True
44
+
45
+ params.append(param_info)
46
+
47
+ docstring = inspect.getdoc(task.fn)
48
+
49
+ tasks.append(
50
+ {
51
+ "name": name,
52
+ "queue": task.queue,
53
+ "priority": task.priority,
54
+ "max_attempts": task.max_attempts,
55
+ "tags": task.tags,
56
+ "params": params,
57
+ "docstring": docstring,
58
+ }
59
+ )
60
+
61
+ return tasks
62
+
63
+
18
64
  def task(
19
65
  queue: str = "default",
20
66
  max_attempts: int = 3,
@@ -28,7 +74,7 @@ def task(
28
74
  Usage:
29
75
  @task(queue="default")
30
76
  def my_task(item_id: int):
31
- return Record({"result": item_id * 2})
77
+ return {"result": item_id * 2}
32
78
 
33
79
  # Enqueue
34
80
  my_task.enqueue(item_id=42)
@@ -151,13 +197,7 @@ class Task:
151
197
  return Future(job_id=job_id)
152
198
 
153
199
  # Not inside a task - execute directly
154
- from fairchild.record import Record
155
-
156
- result = self.fn(*args, **kwargs)
157
- # Unwrap Record so local runs behave like resolved futures
158
- if isinstance(result, Record):
159
- return result.value
160
- return result
200
+ return self.fn(*args, **kwargs)
161
201
 
162
202
  def _serialize_args(self, kwargs: dict[str, Any]) -> dict[str, Any]:
163
203
  """Serialize arguments, converting Futures to their job IDs."""
@@ -255,6 +255,16 @@
255
255
  .job-link:hover {
256
256
  text-decoration: underline;
257
257
  }
258
+ .task-args {
259
+ font-family: "SF Mono", Monaco, monospace;
260
+ font-size: 0.75rem;
261
+ color: var(--text-dim);
262
+ margin-top: 0.25rem;
263
+ max-width: 300px;
264
+ overflow: hidden;
265
+ text-overflow: ellipsis;
266
+ white-space: nowrap;
267
+ }
258
268
 
259
269
  .queues {
260
270
  display: grid;
@@ -354,13 +364,6 @@
354
364
  font-weight: 500;
355
365
  }
356
366
 
357
- #workflows-table tbody tr {
358
- cursor: pointer;
359
- }
360
- #workflows-table tbody tr:hover {
361
- background: var(--bg-hover);
362
- }
363
-
364
367
  /* Enqueue Modal */
365
368
  .modal-overlay {
366
369
  display: none;
@@ -1214,23 +1217,10 @@
1214
1217
  }
1215
1218
  }
1216
1219
 
1217
- async function fetchWorkflows() {
1218
- const res = await fetch("/api/workflows");
1219
- const workflows = await res.json();
1220
-
1221
- const tbody = document.querySelector("#workflows-table tbody");
1222
- tbody.innerHTML = workflows
1223
- .map(
1224
- (wf) => `
1225
- <tr onclick="window.location='/workflows/${wf.workflow_id}'">
1226
- <td>${wf.workflow_name || wf.workflow_id.slice(0, 8)}</td>
1227
- <td>${wf.completed}/${wf.total_jobs} completed</td>
1228
- <td class="mono">${formatTime(wf.started_at)}</td>
1229
- <td class="mono">${wf.finished_at ? formatTime(wf.finished_at) : "-"}</td>
1230
- </tr>
1231
- `,
1232
- )
1233
- .join("");
1220
+ function formatArgs(args) {
1221
+ if (!args || Object.keys(args).length === 0) return "";
1222
+ const str = typeof args === "string" ? args : JSON.stringify(args);
1223
+ return str.length > 50 ? str.slice(0, 50) + "..." : str;
1234
1224
  }
1235
1225
 
1236
1226
  async function fetchJobs(state = "") {
@@ -1240,18 +1230,26 @@
1240
1230
 
1241
1231
  const tbody = document.querySelector("#jobs-table tbody");
1242
1232
  tbody.innerHTML = jobs
1243
- .map(
1244
- (job) => `
1233
+ .map((job) => {
1234
+ const argsDisplay = formatArgs(job.args);
1235
+ const argsTitle =
1236
+ typeof job.args === "string"
1237
+ ? job.args
1238
+ : JSON.stringify(job.args);
1239
+ const argsHtml = argsDisplay
1240
+ ? `<div class="task-args" title="${argsTitle.replace(/"/g, "&quot;")}">${argsDisplay}</div>`
1241
+ : "";
1242
+ return `
1245
1243
  <tr>
1246
1244
  <td class="mono"><a href="/jobs/${job.id}" class="job-link">${job.id}</a></td>
1247
- <td class="truncate">${job.task_name}</td>
1245
+ <td>${job.task_name}${argsHtml}</td>
1248
1246
  <td>${job.queue}</td>
1249
1247
  <td><span class="badge ${job.state}">${job.state}</span></td>
1250
1248
  <td>${job.attempt}/${job.max_attempts}</td>
1251
1249
  <td class="mono">${formatTime(job.inserted_at)}</td>
1252
1250
  </tr>
1253
- `,
1254
- )
1251
+ `;
1252
+ })
1255
1253
  .join("");
1256
1254
  }
1257
1255
 
@@ -1462,7 +1460,6 @@
1462
1460
  fetchStats(),
1463
1461
  fetchQueues(),
1464
1462
  fetchWorkers(currentWorkerState),
1465
- fetchWorkflows(),
1466
1463
  fetchJobs(currentState),
1467
1464
  fetchTimeseries(),
1468
1465
  ]);
@@ -1484,14 +1481,17 @@
1484
1481
  }, 1000);
1485
1482
 
1486
1483
  // Enqueue modal
1487
- let tasksLoaded = false;
1488
1484
  let taskRegistry = {};
1489
1485
 
1490
1486
  async function loadTasks() {
1491
- if (tasksLoaded) return;
1492
1487
  const res = await fetch("/api/tasks");
1493
1488
  const tasks = await res.json();
1494
1489
  const select = document.getElementById("enqueue-task");
1490
+
1491
+ // Clear existing options except the placeholder
1492
+ select.innerHTML = '<option value="">Select a task...</option>';
1493
+ taskRegistry = {};
1494
+
1495
1495
  tasks.forEach((task) => {
1496
1496
  taskRegistry[task.name] = task;
1497
1497
  const option = document.createElement("option");
@@ -1499,7 +1499,6 @@
1499
1499
  option.textContent = `${task.name} (queue: ${task.queue})`;
1500
1500
  select.appendChild(option);
1501
1501
  });
1502
- tasksLoaded = true;
1503
1502
  }
1504
1503
 
1505
1504
  function onTaskSelected() {
@@ -392,6 +392,64 @@
392
392
  border-radius: 4px;
393
393
  }
394
394
 
395
+ /* Task Summary Stats */
396
+ .task-summary {
397
+ margin-bottom: 1rem;
398
+ overflow-x: auto;
399
+ }
400
+ .task-summary table {
401
+ width: 100%;
402
+ border-collapse: collapse;
403
+ font-size: 0.8125rem;
404
+ }
405
+ .task-summary th,
406
+ .task-summary td {
407
+ padding: 0.5rem 0.75rem;
408
+ text-align: left;
409
+ border-bottom: 1px solid var(--border-color);
410
+ }
411
+ .task-summary th {
412
+ background: var(--bg-secondary);
413
+ color: var(--text-muted);
414
+ font-weight: 500;
415
+ text-transform: uppercase;
416
+ font-size: 0.6875rem;
417
+ }
418
+ .task-summary td {
419
+ color: var(--text-secondary);
420
+ }
421
+ .task-summary .task-name {
422
+ font-family: "SF Mono", Monaco, monospace;
423
+ font-weight: 500;
424
+ color: var(--text-primary);
425
+ }
426
+ .task-summary .stat-cell {
427
+ text-align: center;
428
+ min-width: 80px;
429
+ }
430
+ .task-summary .stat-cell .count {
431
+ font-weight: 600;
432
+ }
433
+ .task-summary .stat-cell .pct {
434
+ color: var(--text-dim);
435
+ font-size: 0.75rem;
436
+ margin-left: 0.25rem;
437
+ }
438
+ .task-summary .total-row {
439
+ font-weight: 600;
440
+ background: var(--bg-secondary);
441
+ }
442
+ .task-summary .total-row td {
443
+ border-bottom: none;
444
+ }
445
+ .task-summary .timing-cell {
446
+ white-space: nowrap;
447
+ }
448
+ .task-summary .timing-range {
449
+ color: var(--text-dim);
450
+ font-size: 0.7rem;
451
+ }
452
+
395
453
  #family-table {
396
454
  width: 100%;
397
455
  border-collapse: collapse;
@@ -522,6 +580,9 @@
522
580
  </div>
523
581
  <div id="dag-flow"></div>
524
582
  </div>
583
+ <div class="task-summary" id="task-summary" style="margin-top: 16px">
584
+ <!-- Populated by JS -->
585
+ </div>
525
586
  <table id="family-table" style="margin-top: 16px">
526
587
  <thead>
527
588
  <tr>
@@ -755,10 +816,10 @@
755
816
  );
756
817
 
757
818
  // Result
758
- if (job.recorded !== null && job.recorded !== undefined) {
819
+ if (job.result !== null && job.result !== undefined) {
759
820
  document.getElementById("result-card").style.display = "block";
760
821
  document.getElementById("result-block").textContent = formatJson(
761
- job.recorded,
822
+ job.result,
762
823
  );
763
824
  }
764
825
 
@@ -815,10 +876,21 @@
815
876
 
816
877
  familyData = await res.json();
817
878
 
818
- // Only show DAG if there's more than one job in the family
819
- if (familyData.jobs && familyData.jobs.length > 1) {
879
+ // Find the current job in the family
880
+ const currentJob = familyData.jobs.find((j) => j.id === jobId);
881
+
882
+ // Only show family view if:
883
+ // 1. Current job is the root (no parent_id) AND has children
884
+ // 2. Don't show for child jobs - they should just see their own info
885
+ const isRoot = currentJob && !currentJob.parent_id;
886
+ const hasChildren = familyData.jobs.some(
887
+ (j) => j.parent_id === jobId,
888
+ );
889
+
890
+ if (isRoot && familyData.jobs.length > 1) {
820
891
  document.getElementById("family-card").style.display = "block";
821
892
  renderDAG(familyData.jobs);
893
+ renderTaskSummary(familyData.jobs);
822
894
  renderFamilyTable(familyData.jobs);
823
895
  }
824
896
  } catch (err) {
@@ -1190,6 +1262,149 @@
1190
1262
  );
1191
1263
  }
1192
1264
 
1265
+ function renderTaskSummary(jobs) {
1266
+ const container = document.getElementById("task-summary");
1267
+ const allStates = [
1268
+ "completed",
1269
+ "running",
1270
+ "available",
1271
+ "scheduled",
1272
+ "failed",
1273
+ "discarded",
1274
+ ];
1275
+ const stateLabels = {
1276
+ completed: "Done",
1277
+ running: "Run",
1278
+ available: "Avail",
1279
+ scheduled: "Sched",
1280
+ failed: "Fail",
1281
+ discarded: "Disc",
1282
+ };
1283
+
1284
+ function fmtDur(ms) {
1285
+ if (ms < 1000) return Math.round(ms) + "ms";
1286
+ if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
1287
+ if (ms < 3600000) return (ms / 60000).toFixed(1) + "m";
1288
+ return (ms / 3600000).toFixed(1) + "h";
1289
+ }
1290
+
1291
+ const taskStats = {};
1292
+ let totals = { total: 0, durations: [] };
1293
+ allStates.forEach((s) => (totals[s] = 0));
1294
+
1295
+ jobs.forEach((job) => {
1296
+ const taskName = job.task_name;
1297
+ if (!taskStats[taskName]) {
1298
+ taskStats[taskName] = { total: 0, durations: [] };
1299
+ allStates.forEach((s) => (taskStats[taskName][s] = 0));
1300
+ }
1301
+ taskStats[taskName].total++;
1302
+ taskStats[taskName][job.state] =
1303
+ (taskStats[taskName][job.state] || 0) + 1;
1304
+ totals.total++;
1305
+ totals[job.state] = (totals[job.state] || 0) + 1;
1306
+
1307
+ // Include timing for completed, failed, and discarded jobs
1308
+ if (
1309
+ ["completed", "failed", "discarded"].includes(job.state) &&
1310
+ job.attempted_at &&
1311
+ job.completed_at
1312
+ ) {
1313
+ const duration =
1314
+ new Date(job.completed_at) - new Date(job.attempted_at);
1315
+ taskStats[taskName].durations.push(duration);
1316
+ totals.durations.push(duration);
1317
+ }
1318
+ });
1319
+
1320
+ // Show all state columns for consistency
1321
+ const states = allStates;
1322
+
1323
+ function calcDurationStats(durations) {
1324
+ if (durations.length === 0) return null;
1325
+ const sorted = durations.slice().sort((a, b) => a - b);
1326
+ const sum = sorted.reduce((a, b) => a + b, 0);
1327
+ const p95Index = Math.floor(sorted.length * 0.95);
1328
+ return {
1329
+ avg: sum / sorted.length,
1330
+ p95: sorted[Math.min(p95Index, sorted.length - 1)],
1331
+ };
1332
+ }
1333
+
1334
+ const taskNames = Object.keys(taskStats).sort();
1335
+ const headerCells = states
1336
+ .map((s) => `<th class="stat-cell">${stateLabels[s]}</th>`)
1337
+ .join("");
1338
+
1339
+ const rows = taskNames
1340
+ .map((taskName) => {
1341
+ const stats = taskStats[taskName];
1342
+ const shortName = taskName.split(".").pop();
1343
+ const ds = calcDurationStats(stats.durations);
1344
+
1345
+ const statCells = states
1346
+ .map((s) => {
1347
+ const count = stats[s] || 0;
1348
+ if (count === 0) return `<td class="stat-cell">-</td>`;
1349
+ const pct =
1350
+ stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
1351
+ return `<td class="stat-cell"><span class="count">${count}</span><span class="pct">(${pct}%)</span></td>`;
1352
+ })
1353
+ .join("");
1354
+
1355
+ const timing = ds
1356
+ ? `${fmtDur(ds.avg)}<span class="timing-range"> (${fmtDur(ds.p95)})</span>`
1357
+ : "-";
1358
+
1359
+ return `<tr>
1360
+ <td class="task-name" title="${taskName}">${shortName}</td>
1361
+ <td class="stat-cell">${stats.total}</td>
1362
+ ${statCells}
1363
+ <td class="stat-cell timing-cell">${timing}</td>
1364
+ </tr>`;
1365
+ })
1366
+ .join("");
1367
+
1368
+ const tds = calcDurationStats(totals.durations);
1369
+ const totalCells = states
1370
+ .map((s) => {
1371
+ const count = totals[s] || 0;
1372
+ if (count === 0) return `<td class="stat-cell">-</td>`;
1373
+ const pct =
1374
+ totals.total > 0 ? Math.round((count / totals.total) * 100) : 0;
1375
+ return `<td class="stat-cell"><span class="count">${count}</span><span class="pct">(${pct}%)</span></td>`;
1376
+ })
1377
+ .join("");
1378
+
1379
+ const totalTiming = tds
1380
+ ? `${fmtDur(tds.avg)}<span class="timing-range"> (${fmtDur(tds.p95)})</span>`
1381
+ : "-";
1382
+
1383
+ const totalRow = `<tr class="total-row">
1384
+ <td>Total</td>
1385
+ <td class="stat-cell">${totals.total}</td>
1386
+ ${totalCells}
1387
+ <td class="stat-cell timing-cell">${totalTiming}</td>
1388
+ </tr>`;
1389
+
1390
+ container.innerHTML = `
1391
+ <table>
1392
+ <thead>
1393
+ <tr>
1394
+ <th>Task</th>
1395
+ <th class="stat-cell">#</th>
1396
+ ${headerCells}
1397
+ <th class="stat-cell">Avg (p95)</th>
1398
+ </tr>
1399
+ </thead>
1400
+ <tbody>
1401
+ ${rows}
1402
+ ${totalRow}
1403
+ </tbody>
1404
+ </table>
1405
+ `;
1406
+ }
1407
+
1193
1408
  function renderFamilyTable(jobs) {
1194
1409
  const tbody = document.querySelector("#family-table tbody");
1195
1410
 
@@ -1214,13 +1429,15 @@
1214
1429
  }
1215
1430
 
1216
1431
  let result = "-";
1217
- if (job.recorded !== null && job.recorded !== undefined) {
1218
- const recorded =
1219
- typeof job.recorded === "string"
1220
- ? job.recorded
1221
- : JSON.stringify(job.recorded);
1432
+ if (job.result !== null && job.result !== undefined) {
1433
+ const resultStr =
1434
+ typeof job.result === "string"
1435
+ ? job.result
1436
+ : JSON.stringify(job.result);
1222
1437
  result =
1223
- recorded.length > 30 ? recorded.slice(0, 30) + "..." : recorded;
1438
+ resultStr.length > 30
1439
+ ? resultStr.slice(0, 30) + "..."
1440
+ : resultStr;
1224
1441
  }
1225
1442
 
1226
1443
  return `