fairchild 0.0.5__tar.gz → 0.0.7__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.5 → fairchild-0.0.7}/PKG-INFO +1 -1
  2. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/__init__.py +0 -2
  3. fairchild-0.0.7/fairchild/db/migrations/006_rename_recorded_to_result.sql +2 -0
  4. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/fairchild.py +6 -6
  5. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/job.py +3 -3
  6. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/task.py +2 -8
  7. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/templates/dashboard.html +29 -5
  8. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/templates/job.html +227 -10
  9. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/ui.py +7 -6
  10. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/worker.py +23 -19
  11. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild.egg-info/PKG-INFO +1 -1
  12. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild.egg-info/SOURCES.txt +1 -2
  13. {fairchild-0.0.5 → fairchild-0.0.7}/pyproject.toml +1 -1
  14. {fairchild-0.0.5 → fairchild-0.0.7}/tests/test_cli.py +49 -35
  15. {fairchild-0.0.5 → fairchild-0.0.7}/tests/test_task.py +17 -22
  16. fairchild-0.0.5/fairchild/record.py +0 -22
  17. fairchild-0.0.5/tests/test_record.py +0 -40
  18. {fairchild-0.0.5 → fairchild-0.0.7}/LICENSE +0 -0
  19. {fairchild-0.0.5 → fairchild-0.0.7}/README.md +0 -0
  20. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/cli.py +0 -0
  21. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/context.py +0 -0
  22. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/db/__init__.py +0 -0
  23. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/db/migrations/001_initial.sql +0 -0
  24. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/db/migrations/002_add_parent_id.sql +0 -0
  25. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/db/migrations/003_remove_workflows.sql +0 -0
  26. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/db/migrations/004_add_workers.sql +0 -0
  27. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/db/migrations/005_add_worker_tasks.sql +0 -0
  28. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/db/migrations.py +0 -0
  29. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild/future.py +0 -0
  30. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild.egg-info/dependency_links.txt +0 -0
  31. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild.egg-info/entry_points.txt +0 -0
  32. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild.egg-info/requires.txt +0 -0
  33. {fairchild-0.0.5 → fairchild-0.0.7}/fairchild.egg-info/top_level.txt +0 -0
  34. {fairchild-0.0.5 → fairchild-0.0.7}/setup.cfg +0 -0
  35. {fairchild-0.0.5 → fairchild-0.0.7}/tests/test_integration.py +0 -0
  36. {fairchild-0.0.5 → fairchild-0.0.7}/tests/test_job.py +0 -0
  37. {fairchild-0.0.5 → fairchild-0.0.7}/tests/test_web_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairchild
3
- Version: 0.0.5
3
+ Version: 0.0.7
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
  ]
@@ -0,0 +1,2 @@
1
+ -- Rename recorded column to result
2
+ ALTER TABLE fairchild_jobs RENAME COLUMN recorded TO result;
@@ -146,12 +146,12 @@ class Fairchild:
146
146
 
147
147
  return Job.from_row(dict(row))
148
148
 
149
- async def get_recorded(self, job_id: UUID) -> Any:
150
- """Get the recorded value from a completed job."""
149
+ async def get_result(self, job_id: UUID) -> Any:
150
+ """Get the result value from a completed job."""
151
151
  await self._ensure_connected()
152
152
 
153
153
  query = """
154
- SELECT recorded FROM fairchild_jobs
154
+ SELECT result FROM fairchild_jobs
155
155
  WHERE id = $1
156
156
  """
157
157
 
@@ -159,8 +159,8 @@ class Fairchild:
159
159
  if row is None:
160
160
  return None
161
161
 
162
- recorded = row["recorded"]
163
- if recorded is None:
162
+ result = row["result"]
163
+ if result is None:
164
164
  return None
165
165
 
166
- return json.loads(recorded) if isinstance(recorded, str) else recorded
166
+ return json.loads(result) if isinstance(result, str) else result
@@ -57,7 +57,7 @@ class Job:
57
57
  max_attempts: int = 3
58
58
 
59
59
  # Results & errors
60
- recorded: Any | None = None
60
+ result: Any | None = None
61
61
  errors: list[dict[str, Any]] = field(default_factory=list)
62
62
 
63
63
  # Metadata
@@ -90,7 +90,7 @@ class Job:
90
90
  completed_at=row["completed_at"],
91
91
  attempt=row["attempt"],
92
92
  max_attempts=row["max_attempts"],
93
- recorded=_parse_json(row["recorded"]),
93
+ result=_parse_json(row["result"]),
94
94
  errors=_parse_json(row["errors"]) or [],
95
95
  tags=row.get("tags") or [],
96
96
  meta=_parse_json(row.get("meta")) or {},
@@ -114,7 +114,7 @@ class Job:
114
114
  "completed_at": self.completed_at,
115
115
  "attempt": self.attempt,
116
116
  "max_attempts": self.max_attempts,
117
- "recorded": self.recorded,
117
+ "result": self.result,
118
118
  "errors": self.errors,
119
119
  "tags": self.tags,
120
120
  "meta": self.meta,
@@ -74,7 +74,7 @@ def task(
74
74
  Usage:
75
75
  @task(queue="default")
76
76
  def my_task(item_id: int):
77
- return Record({"result": item_id * 2})
77
+ return {"result": item_id * 2}
78
78
 
79
79
  # Enqueue
80
80
  my_task.enqueue(item_id=42)
@@ -197,13 +197,7 @@ class Task:
197
197
  return Future(job_id=job_id)
198
198
 
199
199
  # Not inside a task - execute directly
200
- from fairchild.record import Record
201
-
202
- result = self.fn(*args, **kwargs)
203
- # Unwrap Record so local runs behave like resolved futures
204
- if isinstance(result, Record):
205
- return result.value
206
- return result
200
+ return self.fn(*args, **kwargs)
207
201
 
208
202
  def _serialize_args(self, kwargs: dict[str, Any]) -> dict[str, Any]:
209
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;
@@ -1207,6 +1217,12 @@
1207
1217
  }
1208
1218
  }
1209
1219
 
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;
1224
+ }
1225
+
1210
1226
  async function fetchJobs(state = "") {
1211
1227
  const url = state ? `/api/jobs?state=${state}` : "/api/jobs";
1212
1228
  const res = await fetch(url);
@@ -1214,18 +1230,26 @@
1214
1230
 
1215
1231
  const tbody = document.querySelector("#jobs-table tbody");
1216
1232
  tbody.innerHTML = jobs
1217
- .map(
1218
- (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 `
1219
1243
  <tr>
1220
1244
  <td class="mono"><a href="/jobs/${job.id}" class="job-link">${job.id}</a></td>
1221
- <td class="truncate">${job.task_name}</td>
1245
+ <td>${job.task_name}${argsHtml}</td>
1222
1246
  <td>${job.queue}</td>
1223
1247
  <td><span class="badge ${job.state}">${job.state}</span></td>
1224
1248
  <td>${job.attempt}/${job.max_attempts}</td>
1225
1249
  <td class="mono">${formatTime(job.inserted_at)}</td>
1226
1250
  </tr>
1227
- `,
1228
- )
1251
+ `;
1252
+ })
1229
1253
  .join("");
1230
1254
  }
1231
1255
 
@@ -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 `
@@ -159,7 +159,7 @@ async def api_jobs(request: web.Request) -> web.Response:
159
159
  parent_id, deps,
160
160
  state, priority, scheduled_at,
161
161
  attempted_at, completed_at, attempt, max_attempts,
162
- recorded, errors, tags,
162
+ result, errors, tags,
163
163
  inserted_at, updated_at
164
164
  FROM fairchild_jobs
165
165
  {where}
@@ -316,7 +316,7 @@ async def api_job_detail(request: web.Request) -> web.Response:
316
316
  parent_id, deps,
317
317
  state, priority, scheduled_at,
318
318
  attempted_at, completed_at, attempt, max_attempts,
319
- recorded, errors, tags,
319
+ result, errors, tags,
320
320
  inserted_at, updated_at
321
321
  FROM fairchild_jobs
322
322
  WHERE id = $1
@@ -382,15 +382,15 @@ async def api_job_family(request: web.Request) -> web.Response:
382
382
  # Get all descendants from the root
383
383
  family_query = """
384
384
  WITH RECURSIVE family AS (
385
- SELECT id, task_name, parent_id, state, deps, recorded,
386
- attempted_at, completed_at, attempt, max_attempts
385
+ SELECT id, task_name, parent_id, state, deps, result,
386
+ attempted_at, completed_at, updated_at, attempt, max_attempts
387
387
  FROM fairchild_jobs
388
388
  WHERE id = $1
389
389
 
390
390
  UNION ALL
391
391
 
392
- SELECT j.id, j.task_name, j.parent_id, j.state, j.deps, j.recorded,
393
- j.attempted_at, j.completed_at, j.attempt, j.max_attempts
392
+ SELECT j.id, j.task_name, j.parent_id, j.state, j.deps, j.result,
393
+ j.attempted_at, j.completed_at, j.updated_at, j.attempt, j.max_attempts
394
394
  FROM fairchild_jobs j
395
395
  INNER JOIN family f ON j.parent_id = f.id
396
396
  )
@@ -409,6 +409,7 @@ async def api_job_family(request: web.Request) -> web.Response:
409
409
  job["completed_at"] = (
410
410
  job["completed_at"].isoformat() if job["completed_at"] else None
411
411
  )
412
+ job["updated_at"] = job["updated_at"].isoformat() if job["updated_at"] else None
412
413
  jobs.append(job)
413
414
 
414
415
  return web.json_response({"root_id": str(root_id), "jobs": jobs})
@@ -10,7 +10,6 @@ from uuid import uuid4
10
10
  from fairchild.context import set_current_job, get_pending_children
11
11
  from fairchild.fairchild import Fairchild
12
12
  from fairchild.job import Job, JobState
13
- from fairchild.record import Record
14
13
  from fairchild.task import get_task, get_task_schemas
15
14
 
16
15
 
@@ -120,9 +119,9 @@ class Worker:
120
119
  # Check if this is a future marker
121
120
  if "__future__" in obj and len(obj) == 1:
122
121
  job_id = obj["__future__"]
123
- # Fetch the recorded result from the completed job
122
+ # Fetch the result from the completed job
124
123
  query = """
125
- SELECT recorded FROM fairchild_jobs
124
+ SELECT result FROM fairchild_jobs
126
125
  WHERE id = $1 AND state = 'completed'
127
126
  """
128
127
  from uuid import UUID
@@ -180,19 +179,23 @@ class Worker:
180
179
  for child_job in pending_children:
181
180
  await self.fairchild._insert_job(child_job)
182
181
 
183
- # Handle Record() return values
184
- recorded_value = None
185
- if isinstance(result, Record):
186
- recorded_value = result.value
182
+ # Try to capture the return value as JSON
183
+ result_value = None
184
+ if result is not None:
185
+ try:
186
+ json.dumps(result) # Test if serializable
187
+ result_value = result
188
+ except (TypeError, ValueError):
189
+ pass # Not JSON-serializable, skip
187
190
 
188
191
  # Check if this job spawned children - if so, wait for them
189
192
  has_children = len(pending_children) > 0
190
193
  if has_children:
191
194
  # Keep job in running state, it will be completed when children finish
192
- await self._mark_waiting_for_children(job, recorded_value)
195
+ await self._mark_waiting_for_children(job, result_value)
193
196
  print(f"[{self.name}] Job {job.id} waiting for children to complete")
194
197
  else:
195
- await self._complete_job(job, recorded_value)
198
+ await self._complete_job(job, result_value)
196
199
  print(f"[{self.name}] Completed job {job.id}")
197
200
 
198
201
  except Exception as e:
@@ -214,19 +217,19 @@ class Worker:
214
217
  f"[{self.name}] Job {job.id} failed (attempt {job.attempt}/{job.max_attempts}): {e}"
215
218
  )
216
219
 
217
- async def _complete_job(self, job: Job, recorded: Any | None):
220
+ async def _complete_job(self, job: Job, result: Any | None):
218
221
  """Mark a job as completed."""
219
222
  query = """
220
223
  UPDATE fairchild_jobs
221
224
  SET state = 'completed',
222
225
  completed_at = now(),
223
- recorded = $2,
226
+ result = $2,
224
227
  updated_at = now()
225
228
  WHERE id = $1
226
229
  """
227
230
 
228
- recorded_json = json.dumps(recorded) if recorded is not None else None
229
- await self.fairchild._pool.execute(query, job.id, recorded_json)
231
+ result_json = json.dumps(result) if result is not None else None
232
+ await self.fairchild._pool.execute(query, job.id, result_json)
230
233
 
231
234
  # Check if this completion unblocks jobs waiting on this one
232
235
  await self._check_child_deps(job)
@@ -246,17 +249,17 @@ class Worker:
246
249
  """
247
250
  return await self.fairchild._pool.fetchval(query, job.id)
248
251
 
249
- async def _mark_waiting_for_children(self, job: Job, recorded: Any | None):
252
+ async def _mark_waiting_for_children(self, job: Job, result: Any | None):
250
253
  """Mark a job as waiting for children (stays in running state but stores result)."""
251
- # Store the recorded value so we can use it when completing later
254
+ # Store the result value so we can use it when completing later
252
255
  query = """
253
256
  UPDATE fairchild_jobs
254
- SET recorded = $2,
257
+ SET result = $2,
255
258
  updated_at = now()
256
259
  WHERE id = $1
257
260
  """
258
- recorded_json = json.dumps(recorded) if recorded is not None else None
259
- await self.fairchild._pool.execute(query, job.id, recorded_json)
261
+ result_json = json.dumps(result) if result is not None else None
262
+ await self.fairchild._pool.execute(query, job.id, result_json)
260
263
 
261
264
  async def _check_parent_completion(self, parent_id):
262
265
  """Check if a parent job can be completed (all children done)."""
@@ -326,6 +329,7 @@ class Worker:
326
329
  UPDATE fairchild_jobs
327
330
  SET state = $2,
328
331
  errors = errors || $3::jsonb,
332
+ completed_at = now(),
329
333
  updated_at = now()
330
334
  WHERE id = $1
331
335
  """
@@ -338,7 +342,7 @@ class Worker:
338
342
  else:
339
343
  query = """
340
344
  UPDATE fairchild_jobs
341
- SET state = $2, updated_at = now()
345
+ SET state = $2, completed_at = now(), updated_at = now()
342
346
  WHERE id = $1
343
347
  """
344
348
  await self.fairchild._pool.execute(query, job.id, new_state)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairchild
3
- Version: 0.0.5
3
+ Version: 0.0.7
4
4
  Summary: Workflow scheduling with PostgreSQL
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -7,7 +7,6 @@ fairchild/context.py
7
7
  fairchild/fairchild.py
8
8
  fairchild/future.py
9
9
  fairchild/job.py
10
- fairchild/record.py
11
10
  fairchild/task.py
12
11
  fairchild/ui.py
13
12
  fairchild/worker.py
@@ -24,11 +23,11 @@ fairchild/db/migrations/002_add_parent_id.sql
24
23
  fairchild/db/migrations/003_remove_workflows.sql
25
24
  fairchild/db/migrations/004_add_workers.sql
26
25
  fairchild/db/migrations/005_add_worker_tasks.sql
26
+ fairchild/db/migrations/006_rename_recorded_to_result.sql
27
27
  fairchild/templates/dashboard.html
28
28
  fairchild/templates/job.html
29
29
  tests/test_cli.py
30
30
  tests/test_integration.py
31
31
  tests/test_job.py
32
- tests/test_record.py
33
32
  tests/test_task.py
34
33
  tests/test_web_ui.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fairchild"
3
- version = "0.0.5"
3
+ version = "0.0.7"
4
4
  description = "Workflow scheduling with PostgreSQL"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,4 +1,5 @@
1
1
  """Integration tests for the fairchild CLI."""
2
+
2
3
  import subprocess
3
4
  import sys
4
5
  import os
@@ -8,29 +9,33 @@ def test_fairchild_run_command():
8
9
  """Test the 'fairchild run' command with orchestrator_n example."""
9
10
  # Set PYTHONPATH to include current directory
10
11
  env = os.environ.copy()
11
- env['PYTHONPATH'] = '.'
12
-
12
+ env["PYTHONPATH"] = "."
13
+
13
14
  # Get the repository root (assuming tests/ is in repo root)
14
15
  repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
-
16
+
16
17
  # Run the command
17
18
  result = subprocess.run(
18
19
  [
19
- sys.executable, '-m', 'fairchild.cli',
20
- 'run',
21
- '-i', 'examples.tasks',
22
- 'examples.tasks.orchestrator_n',
23
- '-a', 'n_items=2'
20
+ sys.executable,
21
+ "-m",
22
+ "fairchild.cli",
23
+ "run",
24
+ "-i",
25
+ "examples.tasks",
26
+ "examples.tasks.orchestrator_n",
27
+ "-a",
28
+ "n_items=2",
24
29
  ],
25
30
  cwd=repo_root,
26
31
  capture_output=True,
27
32
  text=True,
28
- env=env
33
+ env=env,
29
34
  )
30
-
35
+
31
36
  # Check it ran successfully
32
37
  assert result.returncode == 0, f"Command failed with: {result.stderr}"
33
-
38
+
34
39
  # Check expected output
35
40
  output = result.stdout
36
41
  assert "Invoking examples.tasks.orchestrator_n..." in output
@@ -38,32 +43,36 @@ def test_fairchild_run_command():
38
43
  assert "0 * 2 = 0" in output
39
44
  assert "1 * 2 = 2" in output
40
45
  assert "Summing [0, 2] = 2" in output
41
- assert "Result: Record({'spawned': 3})" in output or "Result: Record(value={'spawned': 3})" in output
46
+ assert "Result: {'spawned': 3}" in output
42
47
 
43
48
 
44
49
  def test_fairchild_run_hello():
45
50
  """Test the 'fairchild run' command with a simple hello task."""
46
51
  env = os.environ.copy()
47
- env['PYTHONPATH'] = '.'
48
-
52
+ env["PYTHONPATH"] = "."
53
+
49
54
  repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
50
-
55
+
51
56
  result = subprocess.run(
52
57
  [
53
- sys.executable, '-m', 'fairchild.cli',
54
- 'run',
55
- '-i', 'examples.tasks',
56
- 'examples.tasks.hello',
57
- '-a', 'name=World'
58
+ sys.executable,
59
+ "-m",
60
+ "fairchild.cli",
61
+ "run",
62
+ "-i",
63
+ "examples.tasks",
64
+ "examples.tasks.hello",
65
+ "-a",
66
+ "name=World",
58
67
  ],
59
68
  cwd=repo_root,
60
69
  capture_output=True,
61
70
  text=True,
62
- env=env
71
+ env=env,
63
72
  )
64
-
73
+
65
74
  assert result.returncode == 0, f"Command failed with: {result.stderr}"
66
-
75
+
67
76
  output = result.stdout
68
77
  assert "Invoking examples.tasks.hello..." in output
69
78
  assert "Hello, World!" in output
@@ -74,27 +83,32 @@ def test_fairchild_run_hello():
74
83
  def test_fairchild_run_add():
75
84
  """Test the 'fairchild run' command with add task."""
76
85
  env = os.environ.copy()
77
- env['PYTHONPATH'] = '.'
78
-
86
+ env["PYTHONPATH"] = "."
87
+
79
88
  repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
80
-
89
+
81
90
  result = subprocess.run(
82
91
  [
83
- sys.executable, '-m', 'fairchild.cli',
84
- 'run',
85
- '-i', 'examples.tasks',
86
- 'examples.tasks.add',
87
- '-a', 'a=5',
88
- '-a', 'b=3'
92
+ sys.executable,
93
+ "-m",
94
+ "fairchild.cli",
95
+ "run",
96
+ "-i",
97
+ "examples.tasks",
98
+ "examples.tasks.add",
99
+ "-a",
100
+ "a=5",
101
+ "-a",
102
+ "b=3",
89
103
  ],
90
104
  cwd=repo_root,
91
105
  capture_output=True,
92
106
  text=True,
93
- env=env
107
+ env=env,
94
108
  )
95
-
109
+
96
110
  assert result.returncode == 0, f"Command failed with: {result.stderr}"
97
-
111
+
98
112
  output = result.stdout
99
113
  assert "Invoking examples.tasks.add..." in output
100
114
  assert "5 + 3 = 8" in output
@@ -1,14 +1,15 @@
1
1
  """Tests for task registration and task decorator."""
2
- from fairchild import task, Record
2
+
3
+ from fairchild import task
3
4
 
4
5
 
5
6
  def test_task_decorator():
6
7
  """Test that the @task decorator creates a Task object."""
7
-
8
+
8
9
  @task(queue="test", priority=1)
9
- def simple_task(x: int) -> Record:
10
- return Record({"result": x * 2})
11
-
10
+ def simple_task(x: int) -> dict:
11
+ return {"result": x * 2}
12
+
12
13
  # Check that task has expected attributes
13
14
  assert simple_task.queue == "test"
14
15
  assert simple_task.priority == 1
@@ -16,20 +17,15 @@ def test_task_decorator():
16
17
  # Task name includes full module path
17
18
  assert "simple_task" in simple_task.name
18
19
  assert simple_task.name.endswith(".simple_task")
19
-
20
+
20
21
 
21
22
  def test_task_with_custom_settings():
22
23
  """Test task with all custom settings."""
23
-
24
- @task(
25
- queue="custom",
26
- priority=9,
27
- max_attempts=5,
28
- tags=["test", "custom"]
29
- )
24
+
25
+ @task(queue="custom", priority=9, max_attempts=5, tags=["test", "custom"])
30
26
  def custom_task() -> None:
31
27
  pass
32
-
28
+
33
29
  assert custom_task.queue == "custom"
34
30
  assert custom_task.priority == 9
35
31
  assert custom_task.max_attempts == 5
@@ -38,25 +34,24 @@ def test_task_with_custom_settings():
38
34
 
39
35
  def test_task_execution_without_worker():
40
36
  """Test that tasks can be called directly when not in a worker context."""
41
-
37
+
42
38
  @task()
43
- def add_numbers(a: int, b: int) -> Record:
44
- return Record({"sum": a + b})
45
-
39
+ def add_numbers(a: int, b: int) -> dict:
40
+ return {"sum": a + b}
41
+
46
42
  # When called directly (not in a worker), should execute the function
47
43
  result = add_numbers(5, 3)
48
-
49
- # Record should be unwrapped in direct calls
44
+
50
45
  assert result == {"sum": 8}
51
46
 
52
47
 
53
48
  def test_task_name_derivation():
54
49
  """Test that task names are derived from module and function."""
55
-
50
+
56
51
  @task()
57
52
  def my_function():
58
53
  pass
59
-
54
+
60
55
  # Should include module and function name
61
56
  assert "my_function" in my_function.name
62
57
  assert my_function.name.endswith(".my_function")
@@ -1,22 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Any
3
-
4
-
5
- @dataclass(frozen=True)
6
- class Record:
7
- """Wrapper to indicate a task's return value should be persisted.
8
-
9
- Usage:
10
- @task(queue="default")
11
- def my_task(item_id: int):
12
- result = process(item_id)
13
- return Record({"item_id": item_id, "result": result})
14
-
15
- The recorded value will be stored in the job's `recorded` column
16
- and can be retrieved by downstream jobs in a workflow.
17
- """
18
-
19
- value: Any
20
-
21
- def __repr__(self) -> str:
22
- return f"Record({self.value!r})"
@@ -1,40 +0,0 @@
1
- """Tests for Record class."""
2
- from fairchild import Record
3
-
4
-
5
- def test_record_creation():
6
- """Test creating a Record with a value."""
7
- record = Record({"key": "value"})
8
-
9
- assert record.value == {"key": "value"}
10
-
11
-
12
- def test_record_with_dict():
13
- """Test Record with dictionary value."""
14
- data = {"name": "test", "count": 42}
15
- record = Record(data)
16
-
17
- assert record.value == data
18
- assert record.value["name"] == "test"
19
- assert record.value["count"] == 42
20
-
21
-
22
- def test_record_with_list():
23
- """Test Record with list value."""
24
- data = [1, 2, 3, 4, 5]
25
- record = Record(data)
26
-
27
- assert record.value == data
28
- assert len(record.value) == 5
29
-
30
-
31
- def test_record_with_primitive():
32
- """Test Record with primitive value."""
33
- record_int = Record(42)
34
- assert record_int.value == 42
35
-
36
- record_str = Record("hello")
37
- assert record_str.value == "hello"
38
-
39
- record_bool = Record(True)
40
- assert record_bool.value is True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes