loki-mode 7.5.11 → 7.5.12

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.
@@ -164,6 +164,78 @@ def _sanitize_text_field(value: str) -> str:
164
164
  return cleaned
165
165
 
166
166
 
167
+ def _decode_task_json_list(raw: Optional[str]) -> list:
168
+ """Decode a JSON-encoded list column on the Task model.
169
+
170
+ v7.5.12 enrichment columns (acceptance_criteria, notes, logs) are stored
171
+ as JSON-encoded text. Returns [] for NULL / empty / malformed values so
172
+ the API response is always shape-stable.
173
+ """
174
+ if not raw:
175
+ return []
176
+ try:
177
+ parsed = json.loads(raw)
178
+ except (TypeError, ValueError):
179
+ return []
180
+ return parsed if isinstance(parsed, list) else []
181
+
182
+
183
+ def _encode_task_json_list(value: Any) -> Optional[str]:
184
+ """Encode a list (or list-of-pydantic-models) as JSON for storage.
185
+
186
+ Pydantic models are dumped via .model_dump(mode='json') so nested
187
+ datetimes serialize as ISO strings. Plain dicts go through a
188
+ datetime-aware encoder fallback (PUT requests reach here as dicts
189
+ via model_dump(exclude_unset=True), which leaves datetimes raw).
190
+ Returns None for empty/None input so we don't write empty strings
191
+ into the column.
192
+ """
193
+ if value is None:
194
+ return None
195
+ if not isinstance(value, list) or not value:
196
+ return None
197
+ out = []
198
+ for item in value:
199
+ if hasattr(item, "model_dump"):
200
+ out.append(item.model_dump(mode="json"))
201
+ else:
202
+ out.append(item)
203
+ return json.dumps(out, default=_json_default)
204
+
205
+
206
+ def _json_default(obj: Any) -> Any:
207
+ """JSON encoder fallback for datetime / date objects."""
208
+ if isinstance(obj, datetime):
209
+ return obj.isoformat()
210
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
211
+
212
+
213
+ def _task_response_from_db(task: Any) -> "TaskResponse":
214
+ """Build a TaskResponse from a Task ORM row, decoding JSON columns."""
215
+ payload = {
216
+ "id": task.id,
217
+ "project_id": task.project_id,
218
+ "title": task.title,
219
+ "description": task.description,
220
+ "status": task.status,
221
+ "priority": task.priority,
222
+ "position": task.position,
223
+ "assigned_agent_id": task.assigned_agent_id,
224
+ "parent_task_id": task.parent_task_id,
225
+ "estimated_duration": task.estimated_duration,
226
+ "actual_duration": task.actual_duration,
227
+ "created_at": task.created_at,
228
+ "updated_at": task.updated_at,
229
+ "completed_at": task.completed_at,
230
+ "acceptance_criteria": _decode_task_json_list(
231
+ getattr(task, "acceptance_criteria", None)
232
+ ),
233
+ "notes": _decode_task_json_list(getattr(task, "notes", None)),
234
+ "logs": _decode_task_json_list(getattr(task, "logs", None)),
235
+ }
236
+ return TaskResponse.model_validate(payload)
237
+
238
+
167
239
  class ProjectCreate(BaseModel):
168
240
  """Schema for creating a project."""
169
241
  name: str = Field(..., min_length=1, max_length=255)
@@ -200,6 +272,26 @@ class ProjectResponse(BaseModel):
200
272
  completed_task_count: int = 0
201
273
 
202
274
 
275
+ class TaskNote(BaseModel):
276
+ """A single note attached to a task (v7.5.12)."""
277
+ timestamp: datetime
278
+ author: str = "system"
279
+ body: str
280
+
281
+
282
+ class TaskLog(BaseModel):
283
+ """A single log entry attached to a task (v7.5.12).
284
+
285
+ Written by the runner after each RARV phase (REASON, ACT, REFLECT,
286
+ VERIFY) so the dashboard can show per-iteration progress.
287
+ """
288
+ timestamp: datetime
289
+ iteration: Optional[int] = None
290
+ level: str = "info" # info | warn | error
291
+ phase: Optional[str] = None # REASON | ACT | REFLECT | VERIFY | ...
292
+ message: str
293
+
294
+
203
295
  class TaskCreate(BaseModel):
204
296
  """Schema for creating a task."""
205
297
  project_id: int
@@ -210,6 +302,10 @@ class TaskCreate(BaseModel):
210
302
  position: int = 0
211
303
  parent_task_id: Optional[int] = None
212
304
  estimated_duration: Optional[int] = None
305
+ # v7.5.12 enrichment (additive, all optional, default to empty list).
306
+ acceptance_criteria: list[str] = Field(default_factory=list)
307
+ notes: list[TaskNote] = Field(default_factory=list)
308
+ logs: list[TaskLog] = Field(default_factory=list)
213
309
 
214
310
  @field_validator("title")
215
311
  @classmethod
@@ -227,6 +323,11 @@ class TaskUpdate(BaseModel):
227
323
  assigned_agent_id: Optional[int] = None
228
324
  estimated_duration: Optional[int] = None
229
325
  actual_duration: Optional[int] = None
326
+ # v7.5.12 enrichment. Clients PUT a full replacement list when supplied;
327
+ # omitted fields are left untouched (Pydantic exclude_unset semantics).
328
+ acceptance_criteria: Optional[list[str]] = None
329
+ notes: Optional[list[TaskNote]] = None
330
+ logs: Optional[list[TaskLog]] = None
230
331
 
231
332
 
232
333
  class TaskMove(BaseModel):
@@ -253,6 +354,12 @@ class TaskResponse(BaseModel):
253
354
  created_at: datetime
254
355
  updated_at: datetime
255
356
  completed_at: Optional[datetime]
357
+ # v7.5.12 enrichment. Always present in the response (default to []) so
358
+ # frontend code can rely on the shape; legacy DB rows with NULL columns
359
+ # surface as empty lists via _decode_task_json_list().
360
+ acceptance_criteria: list[str] = Field(default_factory=list)
361
+ notes: list[TaskNote] = Field(default_factory=list)
362
+ logs: list[TaskLog] = Field(default_factory=list)
256
363
 
257
364
 
258
365
  class SessionInfo(BaseModel):
@@ -1428,6 +1535,12 @@ async def list_tasks(
1428
1535
  task_entry["project"] = item["project"]
1429
1536
  if item.get("source"):
1430
1537
  task_entry["source"] = item["source"]
1538
+ # v7.5.12: pass-through enrichment fields so
1539
+ # the dashboard can render notes + per-phase logs.
1540
+ if isinstance(item.get("notes"), list):
1541
+ task_entry["notes"] = item["notes"]
1542
+ if isinstance(item.get("logs"), list):
1543
+ task_entry["logs"] = item["logs"]
1431
1544
  all_tasks.append(task_entry)
1432
1545
  except (json.JSONDecodeError, KeyError):
1433
1546
  pass
@@ -1519,6 +1632,9 @@ async def create_task(
1519
1632
  position=task.position,
1520
1633
  parent_task_id=task.parent_task_id,
1521
1634
  estimated_duration=task.estimated_duration,
1635
+ acceptance_criteria=_encode_task_json_list(task.acceptance_criteria),
1636
+ notes=_encode_task_json_list(task.notes),
1637
+ logs=_encode_task_json_list(task.logs),
1522
1638
  )
1523
1639
  db.add(db_task)
1524
1640
  await db.flush()
@@ -1535,7 +1651,7 @@ async def create_task(
1535
1651
  },
1536
1652
  })
1537
1653
 
1538
- return TaskResponse.model_validate(db_task)
1654
+ return _task_response_from_db(db_task)
1539
1655
 
1540
1656
 
1541
1657
  @app.get("/api/tasks/{task_id}", response_model=TaskResponse)
@@ -1552,7 +1668,7 @@ async def get_task(
1552
1668
  if not task:
1553
1669
  raise HTTPException(status_code=404, detail="Task not found")
1554
1670
 
1555
- return TaskResponse.model_validate(task)
1671
+ return _task_response_from_db(task)
1556
1672
 
1557
1673
 
1558
1674
  @app.put("/api/tasks/{task_id}", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
@@ -1579,6 +1695,11 @@ async def update_task(
1579
1695
  else:
1580
1696
  update_data["completed_at"] = None
1581
1697
 
1698
+ # v7.5.12: encode enrichment list columns as JSON before persisting.
1699
+ for _enrich_col in ("acceptance_criteria", "notes", "logs"):
1700
+ if _enrich_col in update_data:
1701
+ update_data[_enrich_col] = _encode_task_json_list(update_data[_enrich_col])
1702
+
1582
1703
  for field, value in update_data.items():
1583
1704
  setattr(task, field, value)
1584
1705
 
@@ -1596,7 +1717,7 @@ async def update_task(
1596
1717
  },
1597
1718
  })
1598
1719
 
1599
- return TaskResponse.model_validate(task)
1720
+ return _task_response_from_db(task)
1600
1721
 
1601
1722
 
1602
1723
  @app.delete("/api/tasks/{task_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
@@ -1696,7 +1817,7 @@ async def move_task(
1696
1817
  },
1697
1818
  })
1698
1819
 
1699
- return TaskResponse.model_validate(task)
1820
+ return _task_response_from_db(task)
1700
1821
 
1701
1822
 
1702
1823
  # WebSocket endpoint