evals-lab 0.12.0 → 0.14.0

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 (50) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +2 -2
  3. package/bin/run.js +4 -2
  4. package/lab/VERSION +1 -1
  5. package/lab/demo/CREDITS.md +14 -0
  6. package/lab/demo/datasets/demo-1.json +2 -2
  7. package/lab/demo/datasets/demo-2.json +2 -2
  8. package/lab/demo/datasets/documents-type.json +79 -0
  9. package/lab/demo/datasets/documents-valid.json +117 -0
  10. package/lab/demo/datasets/sentiment.json +2 -2
  11. package/lab/demo/datasets/weather-alerts.json +2 -2
  12. package/lab/demo/datasets/wordings.json +1 -1
  13. package/lab/demo/manifest.json +3 -3
  14. package/lab/demo/pipelines/demo-1.json +4 -4
  15. package/lab/demo/pipelines/demo-2.json +4 -4
  16. package/lab/demo/pipelines/documents.json +102 -0
  17. package/lab/demo/pipelines/sentiment.json +4 -4
  18. package/lab/demo/pipelines/weather-alerts.json +9 -5
  19. package/lab/demo/pipelines/wordings.json +3 -3
  20. package/lab/demo/sources/Demo 1/source.json +1 -1
  21. package/lab/demo/sources/Demo 2/source.json +1 -1
  22. package/lab/demo/sources/documents/invoice.pdf +65 -0
  23. package/lab/demo/sources/documents/letter.pdf +63 -0
  24. package/lab/demo/sources/documents/not-a-pdf.pdf +2 -0
  25. package/lab/demo/sources/documents/receipt.pdf +62 -0
  26. package/lab/demo/sources/documents/report.pdf +59 -0
  27. package/lab/demo/sources/documents/source.json +3 -0
  28. package/lab/demo/sources/documents/truncated.pdf +19 -0
  29. package/lab/demo/sources/sentiment/source.json +1 -1
  30. package/lab/demo/sources/weather-alerts/source.json +1 -1
  31. package/lab/evals-core.mjs +382 -84
  32. package/lab/flows/record.mjs +5 -4
  33. package/lab/flows/wdl.mjs +19 -0
  34. package/lab/kinds/list.mjs +77 -23
  35. package/lab/metrics/builtin.mjs +34 -7
  36. package/lab/run-evals.js +33 -12
  37. package/lab/server.py +668 -108
  38. package/lab/web/dist/assets/gallery-qrf0ZGXK.js +5 -0
  39. package/lab/web/dist/assets/main-D6xARg2A.js +16 -0
  40. package/lab/web/dist/assets/main-xVU6J7Gi.css +1 -0
  41. package/lab/web/dist/assets/tokens-C8dqXDHN.js +62 -0
  42. package/lab/web/dist/assets/tokens-tQ7zTPkd.css +1 -0
  43. package/lab/web/dist/gallery.html +3 -3
  44. package/lab/web/dist/index.html +4 -4
  45. package/package.json +1 -1
  46. package/lab/web/dist/assets/gallery-CFntjQFG.js +0 -5
  47. package/lab/web/dist/assets/main-BlTG2i20.css +0 -1
  48. package/lab/web/dist/assets/main-D6MNWqak.js +0 -17
  49. package/lab/web/dist/assets/tokens-DwzzdSIF.css +0 -1
  50. package/lab/web/dist/assets/tokens-bvuNFuYg.js +0 -60
package/lab/server.py CHANGED
@@ -50,6 +50,7 @@ from pathlib import Path
50
50
  # Ollama's own address on this machine, unless OLLAMA_URL names another: the
51
51
  # deployed lab's is set in the Dockerfile, and no default names a host of its.
52
52
  OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
53
+
53
54
  # The password in front of everything but /healthz (#463). Unset means open,
54
55
  # as a lab run on a workstation always has been; the deployed one sets it,
55
56
  # because it keeps setups and API keys and can spend GPU time.
@@ -145,6 +146,16 @@ def host_of(url: str) -> str:
145
146
  return (urllib.parse.urlsplit(url).hostname or "").lower()
146
147
 
147
148
 
149
+ def ollama_shown():
150
+ """OLLAMA as the page may show it, where a connection with no address
151
+ goes: no user or password, should OLLAMA_URL carry one."""
152
+ try:
153
+ u = urllib.parse.urlsplit(OLLAMA)
154
+ return urllib.parse.urlunsplit(u._replace(netloc=u.netloc.rpartition("@")[2]))
155
+ except ValueError:
156
+ return ""
157
+
158
+
148
159
  # The chat endpoint each connection type's request reaches, off the /v1
149
160
  # base -- evals-core.ts's CONNECTION_TYPES, mirrored because the server is
150
161
  # stdlib-only and cannot load it. Ollama's native /api/chat sits at the root;
@@ -311,6 +322,8 @@ FILE_TYPES = {
311
322
  ".dng": "image/x-adobe-dng",
312
323
  ".tif": "image/tiff",
313
324
  ".tiff": "image/tiff",
325
+ # Handed to the model whole, as a document (evals-core.ts DOCUMENT_TYPES).
326
+ ".pdf": "application/pdf",
314
327
  ".txt": "text/plain; charset=utf-8",
315
328
  ".md": "text/markdown; charset=utf-8",
316
329
  ".csv": "text/csv; charset=utf-8",
@@ -362,13 +375,13 @@ _CALL_UPLOADS = {".txt": "text/plain; charset=utf-8",
362
375
  ".csv": "text/csv; charset=utf-8",
363
376
  ".md": "text/markdown; charset=utf-8"}
364
377
  SOURCE_PROVIDERS = {
365
- "http-get": {"label": "HTTP GET", "noun": "call", "tier": "pull", "flag": None,
378
+ "http-get": {"label": "HTTP GET", "noun": "response", "tier": "pull", "flag": None,
366
379
  "uploads": dict(_CALL_UPLOADS)},
367
380
  # The API call provider (docs/dynamic-sources.md § 2): HTTP GET plus a verb,
368
381
  # headers and a body, keyed from a connection -- the same relay fetch, one
369
382
  # layer up. `produce` and `take` are attached below, beside http-get's.
370
383
  # `keyed`: it sends a connection's key, so a pack may not carry one (read_pack).
371
- "api": {"label": "API call", "noun": "call", "tier": "pull", "flag": None, "keyed": True,
384
+ "api": {"label": "API call", "noun": "response", "tier": "pull", "flag": None, "keyed": True,
372
385
  "uploads": dict(_CALL_UPLOADS), "longConfig": {"body": 8000}},
373
386
  # Supplied has no `produce`: the caller hands items in (the CLI's --items, or
374
387
  # an Upload… by hand), so the server fetches and executes nothing -- it only
@@ -655,7 +668,44 @@ def take_record(name, data):
655
668
  return json.dumps(kept, indent=2).encode("utf-8"), None
656
669
 
657
670
 
671
+ def describe_record(data):
672
+ """A kept record's first response, as the Calls table says it (#587):
673
+ one from a run was Recorded when production ran it; one written by hand
674
+ was Added, an uploaded one Uploaded, and an older Edited one Edited --
675
+ each with the reply's status, when it has one. None for bytes that are
676
+ not a record."""
677
+ try:
678
+ value = json.loads(data.decode("utf-8"))
679
+ except (UnicodeDecodeError, ValueError):
680
+ return None
681
+ if not isinstance(value, dict):
682
+ return None
683
+ origin = (value.get("origin") or {}).get("kind")
684
+ res = value.get("result") if isinstance(value.get("result"), dict) else {}
685
+ status = res.get("status") if isinstance(res.get("status"), int) else None
686
+ kind = {"run": "recorded", "hand": "added", "edited": "edited"}.get(origin, "uploaded")
687
+ return {"kind": kind, "status": status,
688
+ "at": value.get("at") if kind == "recorded" and isinstance(value.get("at"), str) else None}
689
+
690
+
691
+ def record_key(data):
692
+ """flows/record.ts's recordKey for a call from a run: its step, run and
693
+ iteration, kept by an edited record too, so Import runs never brings
694
+ production's call back beside the edit. None for a record from no run,
695
+ and for bytes that are not one."""
696
+ try:
697
+ value = json.loads(data.decode("utf-8"))
698
+ except (UnicodeDecodeError, ValueError):
699
+ return None
700
+ origin = value.get("origin") if isinstance(value, dict) else None
701
+ if not isinstance(origin, dict) or not origin.get("run"):
702
+ return None
703
+ return json.dumps({"step": value.get("step"), "run": origin["run"], "iteration": origin.get("iteration") or ""})
704
+
705
+
658
706
  WORKFLOW_PLATFORMS["power-automate"]["take"] = take_record
707
+ WORKFLOW_PLATFORMS["power-automate"]["describe"] = describe_record
708
+ WORKFLOW_PLATFORMS["power-automate"]["key"] = record_key
659
709
 
660
710
 
661
711
  # ---- A dynamic Source's Calls ------------------------------------------------
@@ -1212,9 +1262,20 @@ def workspace_of(store) -> str:
1212
1262
 
1213
1263
 
1214
1264
  # The `connection_shares` sentinels (phase 4 fills and enforces the table;
1215
- # phase 1 only creates it empty): every workspace, and future ones.
1265
+ # phase 1 only creates it empty): every workspace, future ones, and none.
1266
+ # No rows is All, so a set that loses its last workspace (a purge, the Share
1267
+ # link's Undo, a stale id) holds SHARE_NONE rather than nothing (#506).
1216
1268
  SHARE_ALL = "*all"
1217
1269
  SHARE_NEW = "*new"
1270
+ SHARE_NONE = "*none"
1271
+
1272
+ def hold_nowhere(db, conns) -> None:
1273
+ """SHARE_NONE for each (kind, id) in [conns] left with no share row, which
1274
+ would otherwise read as shared with every workspace."""
1275
+ db.executemany("INSERT INTO connection_shares (kind, id, workspace) SELECT ?, ?, ? "
1276
+ "WHERE NOT EXISTS (SELECT 1 FROM connection_shares WHERE kind = ? AND id = ?)",
1277
+ [(k, i, SHARE_NONE, k, i) for k, i in conns])
1278
+
1218
1279
 
1219
1280
  # How the `docs` table scopes by key (docs/workspaces.md): workflows are
1220
1281
  # per-workspace, profiles/tokens global, and promptlab.versions is split --
@@ -1519,7 +1580,8 @@ class Store:
1519
1580
  # An AI Connection or an Account (kind "profile"/"account", id the
1520
1581
  # connection's -- the Target profile's it kept -- or the account's) is
1521
1582
  # usable in a workspace by its share set in `connection_shares`: a row for
1522
- # that workspace, or the SHARE_ALL sentinel.
1583
+ # that workspace, or the SHARE_ALL sentinel. SHARE_NONE is a row that
1584
+ # matches no workspace, so a set holding only it is usable nowhere.
1523
1585
  # No row at all is the migration and new-connection default -- shared with
1524
1586
  # every workspace, so nothing stops running the moment workspaces exist
1525
1587
  # (open question 2, resolved All); narrowing a connection is adding the
@@ -1540,7 +1602,7 @@ class Store:
1540
1602
  """Every connection's share set, for the Connections menus: keyed by
1541
1603
  "<kind>:<id>", the workspace values as stored (workspace ids and the
1542
1604
  sentinels). A connection with no row is absent, which the page reads as
1543
- shared with All. No key is anywhere in this (#258)."""
1605
+ shared with All; one with SHARE_NONE is shared with no workspace. No key is anywhere in this (#258)."""
1544
1606
  out: dict = {}
1545
1607
  with self.lock, closing(sqlite3.connect(self.path)) as db:
1546
1608
  for kind, cid, ws in db.execute("SELECT kind, id, workspace FROM connection_shares"):
@@ -1548,15 +1610,20 @@ class Store:
1548
1610
  return out
1549
1611
 
1550
1612
  def set_shares(self, kind: str, cid: str, workspaces) -> None:
1551
- """Replace a connection's share set. SHARE_ALL means every workspace,
1552
- so it is kept alone; only the sentinels and live workspace ids are let
1553
- in, so a stale id cannot linger in the table."""
1613
+ """Replace a connection's share set. SHARE_ALL means every workspace
1614
+ and SHARE_NONE none, so each is kept alone; only the sentinels and live
1615
+ workspace ids are let in, so a stale id cannot linger in the table. A
1616
+ set named but left empty by that is SHARE_NONE, not the All an empty
1617
+ set reads as; an empty set asked for is a plain delete, so a failed
1618
+ lab import's rollback leaves no row for a connection it never wrote."""
1554
1619
  with self.lock, closing(sqlite3.connect(self.path)) as db, db:
1555
1620
  valid = {r[0] for r in db.execute("SELECT id FROM workspaces WHERE trash IS NULL")}
1556
1621
  chosen = [w for w in dict.fromkeys(workspaces or [])
1557
- if w in (SHARE_ALL, SHARE_NEW) or w in valid]
1622
+ if w in (SHARE_ALL, SHARE_NEW, SHARE_NONE) or w in valid]
1558
1623
  if SHARE_ALL in chosen:
1559
1624
  chosen = [SHARE_ALL]
1625
+ elif SHARE_NONE in chosen or (workspaces and not chosen):
1626
+ chosen = [SHARE_NONE]
1560
1627
  db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ?", (kind, cid))
1561
1628
  db.executemany("INSERT INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
1562
1629
  [(kind, cid, w) for w in chosen])
@@ -1565,14 +1632,18 @@ class Store:
1565
1632
  """Add or remove one workspace from a connection's share set -- the Run
1566
1633
  bar's Share link and its Undo (docs/workspaces.md). The link is offered
1567
1634
  only where the connection is actually narrowed, so adding a row is what
1568
- grants the blocked workspace its use and Undo takes it back."""
1635
+ grants the blocked workspace its use and Undo takes it back -- to
1636
+ SHARE_NONE when it was the last, as a set shared nowhere was."""
1569
1637
  with self.lock, closing(sqlite3.connect(self.path)) as db, db:
1570
1638
  if on:
1571
1639
  db.execute("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) "
1572
1640
  "VALUES (?, ?, ?)", (kind, cid, ws))
1641
+ db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
1642
+ (kind, cid, SHARE_NONE))
1573
1643
  else:
1574
1644
  db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
1575
1645
  (kind, cid, ws))
1646
+ hold_nowhere(db, [(kind, cid)])
1576
1647
 
1577
1648
  def workspace_name(self, ws) -> str:
1578
1649
  """A workspace's name, for the refusal sentence the Run bar shows."""
@@ -1702,8 +1773,11 @@ class Workspaces:
1702
1773
  if isinstance(shares, list)
1703
1774
  else list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?",
1704
1775
  (SHARE_NEW,))))
1776
+ grant = [(k, i) for k, i in grant if k and i]
1705
1777
  db.executemany("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
1706
- [(k, i, wid) for k, i in grant if k and i])
1778
+ [(k, i, wid) for k, i in grant])
1779
+ db.executemany("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
1780
+ [(k, i, SHARE_NONE) for k, i in grant])
1707
1781
  return wid, None
1708
1782
 
1709
1783
  def rename(self, wid, name) -> tuple:
@@ -1822,6 +1896,9 @@ class Workspaces:
1822
1896
  "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1823
1897
  db.execute("DELETE FROM source_definitions WHERE source IN "
1824
1898
  "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1899
+ for table in ("source_calls", "source_responses"):
1900
+ db.execute(f"DELETE FROM {table} WHERE source IN "
1901
+ "(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
1825
1902
  db.execute("DELETE FROM sources WHERE workspace = ? AND system = 0", (ws,))
1826
1903
  db.execute("DELETE FROM eval_group_versions WHERE group_id IN "
1827
1904
  "(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
@@ -1843,7 +1920,11 @@ class Workspaces:
1843
1920
  db.execute("DELETE FROM queue WHERE workspace = ?", (ws,))
1844
1921
  db.execute("DELETE FROM runs WHERE workspace = ?", (ws,))
1845
1922
  db.execute("DELETE FROM docs WHERE workspace = ?", (ws,))
1923
+ # A connection shared only here is shared nowhere after, not with
1924
+ # All, which is what no rows would read as (#506).
1925
+ held = list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?", (ws,)))
1846
1926
  db.execute("DELETE FROM connection_shares WHERE workspace = ?", (ws,))
1927
+ hold_nowhere(db, held)
1847
1928
  db.execute("DELETE FROM workspaces WHERE id = ?", (ws,))
1848
1929
  return sids, rids
1849
1930
 
@@ -2005,6 +2086,17 @@ class Sources:
2005
2086
  "origin TEXT NOT NULL, at TEXT NOT NULL, "
2006
2087
  "provider TEXT, request TEXT, status INTEGER, error TEXT, file TEXT, "
2007
2088
  "PRIMARY KEY (source, name))")
2089
+ # An item's responses (#587): each fetch, import or edit of a
2090
+ # Call or a record kept with its bytes, so Restore can make an
2091
+ # earlier one current again. The file in source_files is the
2092
+ # current one's bytes, and what a run grades; these rows are
2093
+ # only read back by Restore. An item that has had one response
2094
+ # has no rows yet: its first is written when a second arrives.
2095
+ db.execute("CREATE TABLE IF NOT EXISTS source_responses ("
2096
+ "source TEXT NOT NULL, name TEXT NOT NULL, n INTEGER NOT NULL, "
2097
+ "at TEXT NOT NULL, kind TEXT NOT NULL, status INTEGER, body BLOB NOT NULL, "
2098
+ "current INTEGER NOT NULL DEFAULT 0, "
2099
+ "PRIMARY KEY (source, name, n))")
2008
2100
  db.execute("INSERT OR IGNORE INTO sources (id, name, system, bytes, created_at) "
2009
2101
  "VALUES (?, 'Sample library', 1, 0, ?)",
2010
2102
  (self.SYSTEM_ID, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
@@ -2215,10 +2307,11 @@ class Sources:
2215
2307
  if m.get("kind") == "source" and isinstance(m.get("source"), str):
2216
2308
  held.add(m["source"])
2217
2309
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2218
- for (sid,) in db.execute("SELECT DISTINCT source FROM source_definitions "
2219
- "WHERE source NOT IN (SELECT id FROM sources)").fetchall():
2220
- if sid not in held:
2221
- db.execute("DELETE FROM source_definitions WHERE source = ?", (sid,))
2310
+ for table in ("source_definitions", "source_calls", "source_responses"):
2311
+ for (sid,) in db.execute(f"SELECT DISTINCT source FROM {table} "
2312
+ "WHERE source NOT IN (SELECT id FROM sources)").fetchall():
2313
+ if sid not in held:
2314
+ db.execute(f"DELETE FROM {table} WHERE source = ?", (sid,))
2222
2315
 
2223
2316
  def type_of(self, sid):
2224
2317
  """A Source's enforcement entry -- its kind's, or its workflow
@@ -2405,6 +2498,16 @@ class Sources:
2405
2498
  if missing:
2406
2499
  return None, (404, f"{missing[0]!r} is not in that Source")
2407
2500
  total = sum(rows[n][0] for n in names)
2501
+ marks = ",".join("?" * len(names))
2502
+ # A Call's provenance row and an item's responses go to the trash
2503
+ # with its file, so Undo puts the item back as it was.
2504
+ calls = [list(r) for r in db.execute(
2505
+ "SELECT name, origin, at, provider, request, status, error, file FROM source_calls "
2506
+ f"WHERE source = ? AND file IN ({marks})", (sid, *names))]
2507
+ responses = [[r[0], r[1], r[2], r[3], r[4], base64.b64encode(r[5]).decode("ascii"), r[6]]
2508
+ for r in db.execute(
2509
+ "SELECT name, n, at, kind, status, body, current FROM source_responses "
2510
+ f"WHERE source = ? AND name IN ({marks})", (sid, *names))]
2408
2511
  with db:
2409
2512
  for name in names:
2410
2513
  db.execute("DELETE FROM source_files WHERE source = ? AND name = ?",
@@ -2413,6 +2516,8 @@ class Sources:
2413
2516
  # with it (a File Library has no such rows).
2414
2517
  db.execute("DELETE FROM source_calls WHERE source = ? AND file = ?",
2415
2518
  (sid, name))
2519
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ?",
2520
+ (sid, name))
2416
2521
  db.execute("UPDATE sources SET bytes = bytes - ? WHERE id = ?",
2417
2522
  (total, sid))
2418
2523
  token = secrets.token_hex(6)
@@ -2421,7 +2526,8 @@ class Sources:
2421
2526
  (entry / "thumbs").mkdir(parents=True)
2422
2527
  (entry / "manifest.json").write_text(json.dumps({
2423
2528
  "kind": "files", "source": sid, "at": time.time(),
2424
- "files": [{"name": n, "bytes": rows[n][0], "at": rows[n][1]} for n in names]}))
2529
+ "files": [{"name": n, "bytes": rows[n][0], "at": rows[n][1]} for n in names],
2530
+ "calls": calls, "responses": responses}))
2425
2531
  src = self.dir / sid
2426
2532
  for name in names:
2427
2533
  shutil.move(str(src / name), str(entry / "files" / name))
@@ -2486,6 +2592,12 @@ class Sources:
2486
2592
  if r["file"] and r["file"] in sizes:
2487
2593
  call["bytes"] = sizes[r["file"]]
2488
2594
  out.append(call)
2595
+ # When and how its current response came: the Updated column (#587).
2596
+ held = self.responses(sid) or {}
2597
+ for call in out:
2598
+ now = next((x for x in held.get(call["name"], []) if x["current"]), None)
2599
+ if now is not None:
2600
+ call["updated"] = {"kind": now["kind"], "at": now["at"]}
2489
2601
  return out
2490
2602
 
2491
2603
  def _record_call(self, sid, name, origin, provider, request, status, error, file):
@@ -2499,11 +2611,18 @@ class Sources:
2499
2611
  provider, json.dumps(request) if request else None, status, error, file))
2500
2612
 
2501
2613
  def snapshot(self, sid):
2502
- """Produce one Call now and keep it (Snapshot now): the server fetches
2503
- through the provider, the `take` hook refuses a token and redacts, and
2504
- the content is kept as a text file graded as an item. A failed fetch is
2505
- kept as a Call with its error, not dropped. Returns (call, None), or
2506
- (None, error)."""
2614
+ """Fetch now (#587): produce the Source's Call through the provider,
2615
+ the `take` hook refusing a token and redacting, and keep it as a
2616
+ version of the response its name says (#596). A response already held
2617
+ gains the version and keeps its row, whatever was done to it -- an edit
2618
+ is a version too, so nothing forks -- unless the bytes are the current
2619
+ version's, which adds nothing. A name not held is a new response. A
2620
+ failed fetch leaves a response's current version as it is; one with
2621
+ no response yet is kept as a Call with its error, not dropped.
2622
+ Returns ({name, outcome, ...}, None) -- outcome `fetched`,
2623
+ `unchanged`, `added` or `failed`, and for `fetched` the response it
2624
+ replaced (`was`) and the one it added (`n`), what Undo hands back --
2625
+ or (None, error)."""
2507
2626
  got, err = self._dynamic_entry(sid)
2508
2627
  if err:
2509
2628
  return None, err
@@ -2518,46 +2637,231 @@ class Sources:
2518
2637
  request = result.get("request")
2519
2638
  status = result.get("status")
2520
2639
  content = result.get("content")
2521
- if content is None:
2522
- # A failed production: kept as a Call with its error, no file.
2523
- name = self._snapshot_name(sid, dynamic_item_name(cfg, (request or {}).get("url", "")
2524
- or secrets.token_hex(6)))
2525
- self._record_call(sid, name, "snapshot", provider, request, status, result.get("error"), None)
2526
- return {"name": name, "origin": "snapshot", "at": None, "provider": provider,
2527
- "request": request, "status": status, "error": result.get("error")}, None
2528
- name = self._snapshot_name(sid, dynamic_item_name(cfg, content))
2529
- kept, take_err = entry["take"](name, content)
2530
- if take_err:
2531
- code, message = take_err
2532
- self._record_call(sid, name, "snapshot", provider, request, status, message, None)
2533
- return {"name": name, "origin": "snapshot", "at": None, "provider": provider,
2534
- "request": request, "status": status, "error": message}, None
2535
- # The name is stable (the URL's), so a re-snapshot overwrites the item
2536
- # rather than adding a second: remove the old file first, then add. An
2537
- # Edited Call is never the one overwritten (_snapshot_name).
2538
- self.remove_files(sid, [name])
2539
- stored, add_err = self.add_file(sid, name, kept)
2540
- if add_err:
2541
- return None, add_err
2542
- self._record_call(sid, stored, "snapshot", provider, request, status, None, stored)
2543
- return {"name": stored, "origin": "snapshot", "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
2544
- "provider": provider, "request": request, "status": status, "bytes": len(kept)}, None
2545
-
2546
- def _snapshot_name(self, sid, name):
2547
- """The name a snapshot is kept under: [name], unless an Edited Call
2548
- holds it -- a snapshot never overwrites an edit -- and then the first
2549
- of `<stem>-2`, `<stem>-3`... that none does. One that is not Edited is
2550
- overwritten, as the stable name always was."""
2640
+ failure = result.get("error")
2641
+ name = kept = None
2642
+ if content is not None:
2643
+ name = dynamic_item_name(cfg, content)
2644
+ kept, take_err = entry["take"](name, content)
2645
+ if take_err:
2646
+ failure = take_err[1]
2647
+ else:
2648
+ name = dynamic_item_name(cfg, (request or {}).get("url", "") or secrets.token_hex(6))
2649
+ path = self.file_path(sid, name)
2650
+ held = path is not None and path.is_file()
2651
+ if failure is not None:
2652
+ if not held:
2653
+ self._record_call(sid, name, "snapshot", provider, request, status, failure, None)
2654
+ return {"name": name, "outcome": "failed", "provider": provider,
2655
+ "request": request, "status": status, "error": failure}, None
2656
+ if not held:
2657
+ stored, add_err = self.add_file(sid, name, kept)
2658
+ if add_err:
2659
+ return None, add_err
2660
+ self._record_call(sid, stored, "snapshot", provider, request, status, None, stored)
2661
+ return {"name": stored, "outcome": "added", "provider": provider, "request": request,
2662
+ "status": status, "bytes": len(kept)}, None
2663
+ if path.read_bytes() == kept:
2664
+ return {"name": name, "outcome": "unchanged", "status": status}, None
2665
+ added, err = self._respond(sid, name, kept, "fetched", status)
2666
+ if err:
2667
+ return None, err
2668
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2669
+ db.execute("UPDATE source_calls SET request = ?, provider = ?, error = NULL, file = ? "
2670
+ "WHERE source = ? AND name = ?",
2671
+ (json.dumps(request) if request else None, provider, name, sid, name))
2672
+ return {"name": name, "outcome": "fetched", "provider": provider, "request": request,
2673
+ "status": status, "bytes": len(kept), **added}, None
2674
+
2675
+ # ---- an item's responses (#587) -----------------------------------------
2676
+ #
2677
+ # A Call or a record keeps its responses: each fetch, import or edit is
2678
+ # one, kept with its bytes, and the newest -- or the one Restore picks --
2679
+ # is current: its bytes are the file a run grades. Nothing forks; a copy
2680
+ # (Clone) is a new item with a response of its own.
2681
+
2682
+ # How many responses an item keeps: the oldest that is neither current nor
2683
+ # pinned by a pipeline (#596) goes.
2684
+ RESPONSES_KEPT = 20
2685
+ # The kind of response a Call's provenance row says its file was.
2686
+ ORIGIN_KINDS = {"snapshot": "fetched", "run": "fetched", "edited": "edited",
2687
+ "upload": "uploaded", "hand": "added", "copy": "copied"}
2688
+ RESPONSE_KINDS = ("fetched", "recorded", "uploaded", "added", "edited", "copied")
2689
+
2690
+ def _described(self, sid, name, data):
2691
+ """What an item's bytes were before it kept responses: from its Call's
2692
+ provenance row, else from its kind's `describe` hook (a record says
2693
+ whether production recorded it), else an upload, as of the file."""
2551
2694
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2552
- edited = {r[0] for r in db.execute(
2553
- "SELECT name FROM source_calls WHERE source = ? AND origin = 'edited'", (sid,))}
2554
- if name not in edited:
2555
- return name
2556
- stem, ext = os.path.splitext(name)
2557
- n = 2
2558
- while f"{stem}-{n}{ext}" in edited:
2559
- n += 1
2560
- return f"{stem}-{n}{ext}"
2695
+ row = db.execute("SELECT origin, at, status FROM source_calls WHERE source = ? AND name = ?",
2696
+ (sid, name)).fetchone()
2697
+ f = db.execute("SELECT at FROM source_files WHERE source = ? AND name = ?",
2698
+ (sid, name)).fetchone()
2699
+ if row is not None:
2700
+ return {"kind": self.ORIGIN_KINDS.get(row[0], "uploaded"), "at": row[1], "status": row[2]}
2701
+ describe = (self.type_of(sid) or {}).get("describe")
2702
+ got = describe(data) if describe else None
2703
+ out = {"kind": "uploaded", "at": f[0] if f else None, "status": None}
2704
+ if isinstance(got, dict):
2705
+ out.update({k: v for k, v in got.items() if v is not None})
2706
+ return out
2707
+
2708
+ def _respond(self, sid, name, data, kind, status):
2709
+ """[data] as [name]'s new current response: the file rewritten in
2710
+ place, as an edit always was, and the row added. An item with no rows
2711
+ yet first gains one for the bytes it held. Returns ({n, was}, None),
2712
+ or (None, error)."""
2713
+ path = self.file_path(sid, name)
2714
+ if path is None or not path.is_file():
2715
+ return None, (404, f"{name} is not in that Source")
2716
+ old = path.read_bytes()
2717
+ first = None
2718
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2719
+ seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
2720
+ (sid, name)).fetchone() is not None
2721
+ if not seeded:
2722
+ first = self._described(sid, name, old)
2723
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2724
+ folder = self.dir / sid
2725
+ with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2726
+ f.write(data)
2727
+ tmp = f.name
2728
+ try:
2729
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2730
+ total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
2731
+ if total + len(data) - len(old) > MAX_SOURCE:
2732
+ raise _Refused((413, "that would take the source past its cap"))
2733
+ n, was = self._response_rows(db, sid, name, old, first, data, kind, status, now)
2734
+ db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
2735
+ (len(data), sid, name))
2736
+ db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2737
+ except _Refused as no:
2738
+ os.unlink(tmp)
2739
+ return None, no.err
2740
+ os.replace(tmp, folder / name)
2741
+ return {"n": n, "was": was}, None
2742
+
2743
+ def _response_rows(self, db, sid, name, old, first, data, kind, status, now):
2744
+ """The rows behind _respond and an edit, inside the caller's
2745
+ transaction: the bytes [name] held as its first response when it had
2746
+ none ([first] describes them), [data] as the new current one, the
2747
+ oldest past RESPONSES_KEPT dropped -- never one a pipeline pins
2748
+ (#596), which does not count against the rest. Returns (n, was)."""
2749
+ if first is not None:
2750
+ db.execute("INSERT OR IGNORE INTO source_responses (source, name, n, at, kind, status, "
2751
+ "body, current) VALUES (?, ?, 1, ?, ?, ?, ?, 1)",
2752
+ (sid, name, first["at"] or now, first["kind"], first["status"], old))
2753
+ was = db.execute("SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 1",
2754
+ (sid, name)).fetchone()
2755
+ n = db.execute("SELECT COALESCE(MAX(n), 0) + 1 FROM source_responses WHERE source = ? "
2756
+ "AND name = ?", (sid, name)).fetchone()[0]
2757
+ db.execute("UPDATE source_responses SET current = 0 WHERE source = ? AND name = ?", (sid, name))
2758
+ db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
2759
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1)", (sid, name, n, now, kind, status, data))
2760
+ pinned = response_pins(db, sid, name)
2761
+ loose = [r for (r,) in db.execute(
2762
+ "SELECT n FROM source_responses WHERE source = ? AND name = ? AND current = 0 "
2763
+ "ORDER BY n DESC", (sid, name)).fetchall() if r not in pinned]
2764
+ for gone in loose[self.RESPONSES_KEPT - 1:]:
2765
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?", (sid, name, gone))
2766
+ db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?", (status, sid, name))
2767
+ return n, (was[0] if was else None)
2768
+
2769
+ def pinned_body(self, sid, name, n):
2770
+ """The bytes of [name]'s response [n] a run's pin names (#596), or
2771
+ None when the response is gone. `n` 0 is an item that kept one
2772
+ response and no rows: its file, or once it gained rows, the first,
2773
+ which holds those bytes."""
2774
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2775
+ row = db.execute("SELECT body FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2776
+ (sid, name, n or 1)).fetchone()
2777
+ seeded = row is not None or db.execute(
2778
+ "SELECT 1 FROM source_responses WHERE source = ? AND name = ?", (sid, name)).fetchone() is not None
2779
+ if row is not None:
2780
+ return bytes(row[0])
2781
+ if n == 0 and not seeded:
2782
+ path = self.file_path(sid, name)
2783
+ return path.read_bytes() if path is not None and path.is_file() else None
2784
+ return None
2785
+
2786
+ def responses(self, sid):
2787
+ """Every item's responses, newest first, by name: {n, at, kind,
2788
+ status, current} -- no bytes. An item that has kept one response has
2789
+ no rows, and reads as that one, `n` 0, described as it was made. None
2790
+ for no such Source."""
2791
+ if self.get(sid) is None:
2792
+ return None
2793
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2794
+ names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
2795
+ rows = db.execute("SELECT name, n, at, kind, status, current FROM source_responses "
2796
+ "WHERE source = ? ORDER BY n DESC", (sid,)).fetchall()
2797
+ out = {}
2798
+ for name, n, at, kind, status, current in rows:
2799
+ out.setdefault(name, []).append({"n": n, "at": at, "kind": kind, "status": status,
2800
+ "current": bool(current)})
2801
+ for name in names:
2802
+ if name not in out:
2803
+ path = self.file_path(sid, name)
2804
+ data = path.read_bytes() if path is not None and path.is_file() else b""
2805
+ out[name] = [{"n": 0, **self._described(sid, name, data), "current": True}]
2806
+ return out
2807
+
2808
+ def restore_response(self, sid, name, n, drop=None):
2809
+ """Makes [name]'s response [n] current: its bytes the file again, the
2810
+ response it kept, not a new one. [drop] removes a response outright as
2811
+ well -- Undo of a fetch or an edit takes back the one it added. Returns
2812
+ ({name, n}, None), or (None, error)."""
2813
+ entry, err = self._calls_entry(sid)
2814
+ if err:
2815
+ return None, err
2816
+ path = self.file_path(sid, name)
2817
+ if path is None or not path.is_file():
2818
+ return None, (404, f"{name} is not in that Source")
2819
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2820
+ row = db.execute("SELECT body, status FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2821
+ (sid, name, n)).fetchone()
2822
+ if row is None:
2823
+ return None, (404, f"{name} has no such response")
2824
+ data, status = bytes(row[0]), row[1]
2825
+ old = path.read_bytes()
2826
+ folder = self.dir / sid
2827
+ with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2828
+ f.write(data)
2829
+ tmp = f.name
2830
+ try:
2831
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2832
+ total = db.execute("SELECT bytes FROM sources WHERE id = ?", (sid,)).fetchone()[0]
2833
+ if total + len(data) - len(old) > MAX_SOURCE:
2834
+ raise _Refused((413, "that would take the source past its cap"))
2835
+ db.execute("UPDATE source_responses SET current = (n = ?) WHERE source = ? AND name = ?",
2836
+ (n, sid, name))
2837
+ if drop is not None and drop != n:
2838
+ db.execute("DELETE FROM source_responses WHERE source = ? AND name = ? AND n = ?",
2839
+ (sid, name, drop))
2840
+ db.execute("UPDATE source_files SET bytes = ? WHERE source = ? AND name = ?",
2841
+ (len(data), sid, name))
2842
+ db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2843
+ db.execute("UPDATE source_calls SET status = ? WHERE source = ? AND name = ?",
2844
+ (status, sid, name))
2845
+ except _Refused as no:
2846
+ os.unlink(tmp)
2847
+ return None, no.err
2848
+ os.replace(tmp, folder / name)
2849
+ return {"name": name, "n": n}, None
2850
+
2851
+ def key_held(self, sid, key, value):
2852
+ """The name of the file of [sid] whose [key] (its kind's `key` hook)
2853
+ is [value], or None: a record from a run already held is not kept a
2854
+ second time, however it was edited since."""
2855
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2856
+ names = [r[0] for r in db.execute("SELECT name FROM source_files WHERE source = ?", (sid,))]
2857
+ for name in names:
2858
+ path = self.file_path(sid, name)
2859
+ try:
2860
+ if path is not None and key(path.read_bytes()) == value:
2861
+ return name
2862
+ except OSError:
2863
+ continue
2864
+ return None
2561
2865
 
2562
2866
  # ---- a Call opened in the Call dialog: edited, renamed, cloned (#438) ---
2563
2867
  #
@@ -2567,7 +2871,7 @@ class Sources:
2567
2871
  # the rest redacted. A File Library has no hook and no Calls.
2568
2872
 
2569
2873
  # The origins a dynamic Call's provenance row may be put back to (Undo).
2570
- CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited")
2874
+ CALL_ORIGINS = ("snapshot", "run", "upload", "hand", "edited", "copy")
2571
2875
 
2572
2876
  def _calls_entry(self, sid):
2573
2877
  """The enforcement entry of a user Source whose files are Calls, or
@@ -2595,11 +2899,13 @@ class Sources:
2595
2899
  the Source's `take` hook as an upload does. A rename keeps the
2596
2900
  extension and refuses a name in use, or a Call that a dataset's cases
2597
2901
  name ([named_by] answers which datasets do), since a case finds its
2598
- item by name. A dynamic Call's origin becomes `edited` when its text
2599
- changed -- a rename alone is no edit -- unless [origin] puts back the
2600
- one it had (Undo). Nothing is written unless all of it passes.
2601
- Returns ({name, origin, bytes}, None), or (None, (code, sentence)) --
2602
- (code, sentence, "name") when the Name field is what is wrong."""
2902
+ item by name. Changed text is a new response, `edited` -- a rename
2903
+ alone is no edit -- and the item's responses follow a rename. [origin]
2904
+ puts back a dynamic Call's provenance (an older page's Undo). Nothing
2905
+ is written unless all of it passes. Returns ({name, origin, bytes} and,
2906
+ for an edit, the response it added `n` and the one it replaced `was`,
2907
+ None), or (None, (code, sentence)) -- (code, sentence, "name") when
2908
+ the Name field is what is wrong."""
2603
2909
  entry, err = self._calls_entry(sid)
2604
2910
  if err:
2605
2911
  return None, err
@@ -2636,12 +2942,26 @@ class Sources:
2636
2942
  if take_err:
2637
2943
  return None, take_err
2638
2944
  changed = data != old
2945
+ first = status = None
2946
+ if changed:
2947
+ with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
2948
+ seeded = db.execute("SELECT 1 FROM source_responses WHERE source = ? AND name = ?",
2949
+ (sid, name)).fetchone() is not None
2950
+ held = db.execute("SELECT status FROM source_calls WHERE source = ? AND name = ?",
2951
+ (sid, name)).fetchone()
2952
+ if not seeded:
2953
+ first = self._described(sid, name, old)
2954
+ # An edited reply says its own status (a record's); an edited Call
2955
+ # keeps the status of the fetch it was.
2956
+ described = entry["describe"](data) if entry.get("describe") else None
2957
+ status = (described or {}).get("status") if described is not None else (held[0] if held else None)
2639
2958
  folder = self.dir / sid
2640
2959
  tmp = None
2641
2960
  if changed or new != name:
2642
2961
  with tempfile.NamedTemporaryFile("wb", dir=folder, delete=False) as f:
2643
2962
  f.write(data)
2644
2963
  tmp = f.name
2964
+ added = {}
2645
2965
  try:
2646
2966
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2647
2967
  if new != name and new in self._names_held(db, sid):
@@ -2652,13 +2972,18 @@ class Sources:
2652
2972
  db.execute("UPDATE source_files SET name = ?, bytes = ? WHERE source = ? AND name = ?",
2653
2973
  (new, len(data), sid, name))
2654
2974
  db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (len(data) - len(old), sid))
2975
+ db.execute("UPDATE source_responses SET name = ? WHERE source = ? AND name = ?", (new, sid, name))
2655
2976
  row = db.execute("SELECT origin FROM source_calls WHERE source = ? AND name = ?",
2656
2977
  (sid, name)).fetchone()
2657
2978
  now = None
2658
2979
  if row is not None:
2659
- now = origin or ("edited" if changed else row[0])
2980
+ now = origin or row[0]
2660
2981
  db.execute("UPDATE source_calls SET name = ?, file = ?, origin = ? "
2661
2982
  "WHERE source = ? AND name = ?", (new, new, now, sid, name))
2983
+ if changed:
2984
+ n, was = self._response_rows(db, sid, new, old, first, data, "edited", status,
2985
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
2986
+ added = {"n": n, "was": was}
2662
2987
  except _Refused as no:
2663
2988
  if tmp:
2664
2989
  os.unlink(tmp)
@@ -2667,13 +2992,14 @@ class Sources:
2667
2992
  os.replace(tmp, folder / new)
2668
2993
  if new != name:
2669
2994
  (folder / name).unlink(missing_ok=True)
2670
- return {"name": new, "origin": now, "bytes": len(data)}, None
2995
+ return {"name": new, "origin": now, "bytes": len(data), **added}, None
2671
2996
 
2672
2997
  def clone_call(self, sid, name):
2673
- """A copy of one Call under `<stem>-copy` (then `-copy-2`, ...), its
2674
- file and, for a dynamic Call, its provenance -- origin, request,
2675
- status and when it was produced -- as they are. Returns ({name}, None),
2676
- or (None, error)."""
2998
+ """A copy of one Call under `<stem>-copy` (then `-copy-2`, ...): its
2999
+ current bytes as the copy's one response, `copied`, and for a dynamic
3000
+ Call its request and provider, its origin `copy` -- so a fetch never
3001
+ reaches it and a live run keeps it as saved (#587). Returns ({name},
3002
+ None), or (None, error)."""
2677
3003
  entry, err = self._calls_entry(sid)
2678
3004
  if err:
2679
3005
  return None, err
@@ -2690,13 +3016,18 @@ class Sources:
2690
3016
  if copy not in held:
2691
3017
  break
2692
3018
  n += 1
2693
- stored, err = self.add_file(sid, copy, path.read_bytes())
3019
+ data = path.read_bytes()
3020
+ stored, err = self.add_file(sid, copy, data)
2694
3021
  if err:
2695
3022
  return None, err
3023
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2696
3024
  with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
2697
3025
  db.execute("INSERT INTO source_calls (source, name, origin, at, provider, request, status, error, file) "
2698
- "SELECT source, ?, origin, at, provider, request, status, error, ? "
2699
- "FROM source_calls WHERE source = ? AND name = ?", (stored, stored, sid, name))
3026
+ "SELECT source, ?, 'copy', ?, provider, request, status, error, ? "
3027
+ "FROM source_calls WHERE source = ? AND name = ?", (stored, now, stored, sid, name))
3028
+ # A copy starts its own responses: a fetch never reaches it.
3029
+ db.execute("INSERT INTO source_responses (source, name, n, at, kind, status, body, current) "
3030
+ "VALUES (?, ?, 1, ?, 'copied', NULL, ?, 1)", (sid, stored, now, data))
2700
3031
  return {"name": stored}, None
2701
3032
 
2702
3033
  def remove_calls(self, sid, names):
@@ -2792,6 +3123,14 @@ class Sources:
2792
3123
  "VALUES (?, ?, ?, ?)",
2793
3124
  (sid, f["name"], f.get("bytes", 0), f.get("at", "")))
2794
3125
  db.execute("UPDATE sources SET bytes = bytes + ? WHERE id = ?", (total, sid))
3126
+ # An entry from before calls and responses were trashed has neither.
3127
+ for c in m.get("calls") or []:
3128
+ db.execute("INSERT OR REPLACE INTO source_calls (source, name, origin, at, provider, "
3129
+ "request, status, error, file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (sid, *c))
3130
+ for r in m.get("responses") or []:
3131
+ db.execute("INSERT OR REPLACE INTO source_responses (source, name, n, at, kind, status, "
3132
+ "body, current) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
3133
+ (sid, r[0], r[1], r[2], r[3], r[4], base64.b64decode(r[5]), r[6]))
2795
3134
  th = self.dir / sid / ".thumbs"
2796
3135
  th.mkdir(parents=True, exist_ok=True)
2797
3136
  for f in files:
@@ -3457,6 +3796,26 @@ def pins_in(workflows) -> set:
3457
3796
  return out
3458
3797
 
3459
3798
 
3799
+ def response_pins(db, sid, name) -> set:
3800
+ """Every response `n` of Source [sid]'s item [name] a stored pipeline pins
3801
+ (#596), across every workspace's promptlab.workflows document, read in
3802
+ the caller's transaction. A pin of `n` 0 -- an item before it kept rows --
3803
+ pins the first row, which holds those bytes."""
3804
+ out = set()
3805
+ for (body,) in db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows'"):
3806
+ try:
3807
+ listed = (json.loads(body) or {}).get("list") if body else None
3808
+ except (ValueError, TypeError, AttributeError):
3809
+ continue
3810
+ for w in listed if isinstance(listed, list) else []:
3811
+ c = content_of(w.get("work")) if isinstance(w, dict) else None
3812
+ pin = c.get("pin") if isinstance(c, dict) else None
3813
+ if (isinstance(pin, dict) and isinstance(c.get("ref"), dict) and c["ref"].get("id") == sid
3814
+ and pin.get("name") == name and type(pin.get("n")) is int):
3815
+ out.add(pin["n"] or 1)
3816
+ return out
3817
+
3818
+
3460
3819
  def group_key(ref) -> str:
3461
3820
  """The key a run keeps a group's body under: `<id>@<n>`, or the id alone
3462
3821
  for a reference from before the lab numbered versions."""
@@ -4411,8 +4770,8 @@ class Datasets:
4411
4770
 
4412
4771
  def save(self, did, version, body):
4413
4772
  """The body, written at [version] -- the one it began from. Returns
4414
- ({version}, None), or (None, error); a stale version's error carries
4415
- the current row."""
4773
+ ({version, versions}, None), or (None, error); a stale version's error
4774
+ carries the current row."""
4416
4775
  if type(version) is not int:
4417
4776
  return None, (400, "a save names the version it began from")
4418
4777
  # A body of an earlier version -- from a page loaded before this one --
@@ -4431,7 +4790,9 @@ class Datasets:
4431
4790
  self._keep(db, r, text)
4432
4791
  db.execute("UPDATE datasets SET body = ?, version = ?, updated_at = ? WHERE id = ?",
4433
4792
  (text, version + 1, self._now(), did))
4434
- return {"version": version + 1}, None
4793
+ # The save may have cut a version: a link pins the newest (#547).
4794
+ newest = self._head(db, did)["n"]
4795
+ return {"version": version + 1, "versions": newest}, None
4435
4796
 
4436
4797
  def remove(self, did):
4437
4798
  """Into the trash, at once; Undo restores it. Returns (token, None)."""
@@ -4863,8 +5224,8 @@ class FilterSets:
4863
5224
 
4864
5225
  def save(self, fid, version, body):
4865
5226
  """The body, written at [version] -- the one it began from. Returns
4866
- ({version}, None), or (None, error); a stale version's error carries
4867
- the current row."""
5227
+ ({version, versions}, None), or (None, error); a stale version's error
5228
+ carries the current row."""
4868
5229
  if type(version) is not int:
4869
5230
  return None, (400, "a save names the version it began from")
4870
5231
  body = upgrade_filter_set_body(body)
@@ -4881,7 +5242,9 @@ class FilterSets:
4881
5242
  self._keep(db, r, text)
4882
5243
  db.execute("UPDATE filter_sets SET body = ?, version = ?, updated_at = ? WHERE id = ?",
4883
5244
  (text, version + 1, self._now(), fid))
4884
- return {"version": version + 1}, None
5245
+ # The save may have cut a version: a link pins the newest (#547).
5246
+ newest = self._head(db, fid)["n"]
5247
+ return {"version": version + 1, "versions": newest}, None
4885
5248
 
4886
5249
  def remove(self, fid):
4887
5250
  """Into the trash, at once; Undo restores it. Returns (token, None)."""
@@ -5141,6 +5504,54 @@ def demo_pack() -> bytes:
5141
5504
  return buf.getvalue()
5142
5505
 
5143
5506
 
5507
+ # The connection types a prompt cannot be asked of (evals-core.ts: their
5508
+ # `answers` lacks "prompt", or they answer from the item and are no model).
5509
+ NO_WORDS = {"echo", "recorded", "http"}
5510
+
5511
+
5512
+ # The built-in connection types that read a document whole, and the suffixes
5513
+ # that are one: evals-core.ts's `documents: true` and DOCUMENT_TYPES (#558).
5514
+ DOCUMENT_CONNECTIONS = ("openai-compatible", "anthropic")
5515
+ DOCUMENT_SUFFIXES = (".pdf",)
5516
+
5517
+
5518
+ def pack_connection(ws, documents=False):
5519
+ """The AI Connection a pack's target answers on where it names one this
5520
+ lab lacks (#550): the lab's grader, or else the first connection that is
5521
+ asked words, either shared with workspace [ws]; None where there is none,
5522
+ so the target keeps its placeholder and the Run bar says to add one. A
5523
+ pipeline that sends [documents] looks first among the connections that
5524
+ read one (#560), so the Run bar has nothing to refuse where the lab has
5525
+ one."""
5526
+ body = ((STORE.all().get("promptlab.connections") or {}).get("body") or {}) if STORE is not None else {}
5527
+ words = [c for c in body.get("list") or []
5528
+ if isinstance(c, dict) and isinstance(c.get("id"), str) and c["id"]
5529
+ and c.get("type") not in NO_WORDS and STORE.shared("profile", c["id"], ws)]
5530
+ if documents:
5531
+ words = [c for c in words if c.get("type") in DOCUMENT_CONNECTIONS] or words
5532
+ return next((c for c in words if c["id"] == body.get("grader")), words[0] if words else None)
5533
+
5534
+
5535
+ def repoint_targets(doc: dict, ws, documents=False) -> None:
5536
+ """A pack pipeline's targets that name a connection this lab does not have,
5537
+ pointed at the one pack_connection picks, its profile dropped with it."""
5538
+ targets = doc.get("targets")
5539
+ if not isinstance(targets, list) or STORE is None:
5540
+ return
5541
+ have = {c.get("id") for c in ((STORE.all().get("promptlab.connections") or {}).get("body") or {}).get("list") or []
5542
+ if isinstance(c, dict)}
5543
+ pick = None
5544
+ for t in targets:
5545
+ ref = t.get("connection") if isinstance(t, dict) else None
5546
+ if not isinstance(ref, dict) or ref.get("id") in have:
5547
+ continue
5548
+ pick = pick or pack_connection(ws, documents)
5549
+ if pick is None:
5550
+ return
5551
+ t["connection"] = {"id": pick["id"], "name": pick.get("name") or pick["id"]}
5552
+ t["profile"] = None
5553
+
5554
+
5144
5555
  def pack_requires_problem(manifest: dict, plugins: set) -> str:
5145
5556
  """Why this lab cannot take a pack its manifest describes, or ""."""
5146
5557
  req = manifest.get("requires") or {}
@@ -5529,10 +5940,15 @@ class Packs:
5529
5940
  ref.clear()
5530
5941
  ref.update({"id": did, "name": DATASETS.get(did)["name"]})
5531
5942
  content = content_of(doc)
5943
+ documents = False
5532
5944
  if isinstance(content, dict) and isinstance(content.get("ref"), dict):
5533
- sid = src_ids.get(content["ref"].get("id")) or src_ids.get(content["ref"].get("name"))
5945
+ ref = content["ref"]
5946
+ files = pack["sources"].get(ref.get("id")) or pack["sources"].get(ref.get("name")) or []
5947
+ documents = any(Path(f).suffix.lower() in DOCUMENT_SUFFIXES for f, _ in files)
5948
+ sid = src_ids.get(ref.get("id")) or src_ids.get(ref.get("name"))
5534
5949
  if sid:
5535
5950
  content["ref"] = {"id": sid, "name": SOURCES.get(sid)["name"]}
5951
+ repoint_targets(doc, ws, documents)
5536
5952
  new_work.append((key, doc))
5537
5953
  def place(body):
5538
5954
  body.setdefault("list", [])
@@ -6459,7 +6875,8 @@ class Queue:
6459
6875
  # a dynamic Source graded live is fetched at submit and kept here,
6460
6876
  # by item name, so the worker stays a pure dir-reader -- _materialise
6461
6877
  # writes these into the run dir rather than copying a Source file. A
6462
- # snapshot run keeps none and reads the Source's stored Calls.
6878
+ # run over a pinned response (#596) keeps that response's bytes here
6879
+ # too; a snapshot run keeps none and reads the Source's stored Calls.
6463
6880
  if "produced" not in cols:
6464
6881
  db.execute("ALTER TABLE queue ADD COLUMN produced TEXT")
6465
6882
  # The workspace a run belongs to (docs/workspaces.md): History is
@@ -6643,7 +7060,8 @@ class Queue:
6643
7060
  def produced(self, rid):
6644
7061
  """A live dynamic run's produced item bytes, by item name, or None for
6645
7062
  a run that produced none (a snapshot run, or any non-dynamic run). The
6646
- values are the text the server fetched at submit, kept so a start, a
7063
+ values are the text the server fetched at submit, or the pinned
7064
+ response's (#596), kept so a start, a
6647
7065
  resume and a re-run grade the same bytes (docs/dynamic-sources.md)."""
6648
7066
  with self.lock, closing(sqlite3.connect(self.store.path)) as db:
6649
7067
  r = db.execute("SELECT produced FROM queue WHERE id = ?", (rid,)).fetchone()
@@ -7005,7 +7423,8 @@ class Queue:
7005
7423
  # A live dynamic run carries its items' bytes with the row, fetched at
7006
7424
  # submit (docs/dynamic-sources.md): they are written into the run dir
7007
7425
  # rather than copied from a Source, so the worker still just reads a
7008
- # directory of files. A snapshot run has none and reads the Source.
7426
+ # directory of files. A pinned run (#596) carries its response's bytes
7427
+ # the same way. A snapshot run has none and reads the Source.
7009
7428
  live = self.produced(run["id"])
7010
7429
  rundir = self.dir / run["id"]
7011
7430
  files_dir = rundir / "files"
@@ -7473,10 +7892,12 @@ def worker_destinations(run: dict):
7473
7892
  # (answer_key), each entry naming the connection whose key it takes (#445).
7474
7893
  # 17: an HTTP Request target has no `api` of its own; its job's flow step's
7475
7894
  # is the one its body is read by (#477).
7476
- PIPELINE_VERSION = 17
7895
+ # 18: a dynamic Source's content graded as saved pins one response, `pin:
7896
+ # {name, n}`, which a fetch, an edit or a Restore never moves (#596).
7897
+ PIPELINE_VERSION = 18
7477
7898
  # What a stored run may be: the current version, and the ones evals-core.ts's
7478
7899
  # upgradePipeline reads. A new submission is upgraded to the current one.
7479
- READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)
7900
+ READABLE_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18)
7480
7901
  TARGET_CAP = 4
7481
7902
  # Target steps whose words the Prompt library does not record as a use: they
7482
7903
  # ask no model (evals-core.ts's STEP_TYPES.echo).
@@ -7518,10 +7939,12 @@ CONNECTION_TYPES = {
7518
7939
  "presencePenalty", "dryMultiplier", "dryBase", "dryAllowedLength", "grammar",
7519
7940
  "imagePosition", "cachePrompt", "thinking", "rejectLoops"),
7520
7941
  }
7521
- # Types that answer from the item and send no request (Echo, #97): no
7522
- # address to reach, no key to carry, so the destination rules have nothing to
7523
- # say about them. A plugin's type joins with `"local": true`.
7524
- LOCAL_CONNECTIONS = {"echo"}
7942
+ # Types that answer from the item and send no request (Echo, #97; Recorded,
7943
+ # #299): no address to reach, no key to carry, so the destination rules have
7944
+ # nothing to say about them. A target step stands in for each now, so a stored
7945
+ # row of one is never exported (lab_export). A plugin's type joins with
7946
+ # `"local": true`.
7947
+ LOCAL_CONNECTIONS = {"echo", "recorded"}
7525
7948
  # The built-in types, kept apart so a plugin's can be taken away again.
7526
7949
  BUILTIN_CONNECTION_TYPES = dict(CONNECTION_TYPES)
7527
7950
  BUILTIN_LOCAL = set(LOCAL_CONNECTIONS)
@@ -7905,6 +8328,10 @@ def run_problems(run):
7905
8328
  bad.append("a run over a Source needs its file list")
7906
8329
  elif not isinstance(files, list) or not files or not all(isinstance(f, str) for f in files):
7907
8330
  bad.append("a run over a Source needs its file list")
8331
+ pin = content.get("pin")
8332
+ if pin is not None and not (isinstance(pin, dict) and isinstance(pin.get("name"), str) and pin["name"]
8333
+ and type(pin.get("n")) is int and pin["n"] >= 0):
8334
+ bad.append("a Source content's pin names a response as { name, n }")
7908
8335
  elif kind == "text":
7909
8336
  if not isinstance(content.get("text"), str) or not content["text"].strip():
7910
8337
  bad.append("a run over text needs some text")
@@ -7967,6 +8394,11 @@ class NoRedirects(urllib.request.HTTPRedirectHandler):
7967
8394
  # -- a host nobody put on the list -- and the relay reported 200. An
7968
8395
  # allow-list that an environment variable can route around is not one.
7969
8396
  OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects)
8397
+ # urllib's own "Python-urllib/3.x" is refused outright by many public APIs
8398
+ # behind Cloudflare (#512), so every outbound request names the lab instead --
8399
+ # honestly, never a browser. A request's own User-Agent, such as an API call
8400
+ # Source's Headers, wins: urllib adds this only where none is set.
8401
+ OPENER.addheaders = [("User-Agent", "evals-lab")]
7970
8402
 
7971
8403
 
7972
8404
  class Handler(BaseHTTPRequestHandler):
@@ -8158,7 +8590,8 @@ class Handler(BaseHTTPRequestHandler):
8158
8590
  # its own, and only in a lab with a store to keep it in.
8159
8591
  return self._json(200, {"targetCap": TARGET_CAP, "microsoft": microsoft_config(),
8160
8592
  "microsoftEditable": MICROSOFT is not None and not M365_CLIENT_ID,
8161
- "dynamicProviders": dynamic_providers_config()})
8593
+ "dynamicProviders": dynamic_providers_config(),
8594
+ "ollama": ollama_shown()})
8162
8595
  if path == "/api/state":
8163
8596
  if STORE is None:
8164
8597
  return self._send(404, b"not found", "text/plain")
@@ -8965,16 +9398,45 @@ class Handler(BaseHTTPRequestHandler):
8965
9398
  live = content.get("live")
8966
9399
  if live is None:
8967
9400
  live = str(cfg.get("live")).lower() in ("1", "true", "yes", "on") or not src.get("files")
8968
- if live:
9401
+ if live and content["files"]:
9402
+ # Live data over held items (#587): each response the
9403
+ # document names is fetched again under its own name,
9404
+ # so its case applies; a copy, an upload or an item
9405
+ # written by hand is graded as saved.
9406
+ produced, err = self._produce_endpoints(sid, entry, cfg, content["files"])
9407
+ if err:
9408
+ return self._json(err[0], {"error": err[1]})
9409
+ content["live"] = True
9410
+ content["revs"] = {name: "live:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
9411
+ for name, text in produced.items()}
9412
+ elif live:
8969
9413
  produced, name, err = self._produce_live(sid, entry, cfg)
8970
9414
  if err:
8971
9415
  return self._json(err[0], {"error": err[1]})
8972
9416
  content["live"] = True
8973
- content["files"] = [name]
9417
+ # The one capture, looped as a snapshot's files are
9418
+ # (evals-core.ts expand): Run first cannot cut below
9419
+ # one item, and Loop repeats it for reply variance (#513).
9420
+ loops = content.get("loops")
9421
+ content["files"] = [name] * (loops if type(loops) is int and loops >= 1 else 1)
8974
9422
  content["revs"] = {name: "live:" + hashlib.sha256(
8975
9423
  produced[name].encode("utf-8")).hexdigest()}
8976
9424
  else:
8977
9425
  content["live"] = False
9426
+ pin = content.get("pin")
9427
+ if not content["live"] and isinstance(pin, dict):
9428
+ # Saved response pins one (#596): the run grades that
9429
+ # response's bytes, kept with the row as a live run's are,
9430
+ # whatever a fetch, an edit or a Restore made current since.
9431
+ body = SOURCES.pinned_body(sid, pin["name"], pin["n"])
9432
+ if body is None:
9433
+ return self._json(400, {"error": f"the response this run grades, {pin['name']}, "
9434
+ "was removed: pick another"})
9435
+ text = body.decode("utf-8", errors="replace")
9436
+ produced = {pin["name"]: text}
9437
+ loops = content.get("loops")
9438
+ content["files"] = [pin["name"]] * (loops if type(loops) is int and loops >= 1 else 1)
9439
+ content["revs"] = {pin["name"]: "pin:" + hashlib.sha256(body).hexdigest()}
8978
9440
  if produced is None:
8979
9441
  # Snapshot (or a non-dynamic Source): pin the Source's own files
8980
9442
  # at their current revision, the server's to say whatever the
@@ -9044,6 +9506,43 @@ class Handler(BaseHTTPRequestHandler):
9044
9506
  return self._json(201, {"run": QUEUE.submit(run, groups=groups, filter_sets=filter_sets or None,
9045
9507
  produced=produced)})
9046
9508
 
9509
+ @staticmethod
9510
+ def _produce_endpoints(sid, entry, cfg, files):
9511
+ """Every item [files] names, for a live run over a Source that holds
9512
+ some (#587): a response -- a Call fetched from a request -- fetched
9513
+ now from the request it was, under its own name; any other item (a
9514
+ copy, an upload, one by hand) its saved bytes. Returns ({name: text},
9515
+ None), or (None, (code, sentence)) -- a fetch that fails refuses the
9516
+ submit, naming the response."""
9517
+ rows = {c["name"]: c for c in SOURCES.calls(sid) or []}
9518
+ out = {}
9519
+ for name in dict.fromkeys(files):
9520
+ call = rows.get(name) or {}
9521
+ request = call.get("request") if isinstance(call.get("request"), dict) else {}
9522
+ if call.get("origin") in ("snapshot", "run", "edited") and request.get("url"):
9523
+ # The request it was: a response whose Source has moved on
9524
+ # since is still asked what it was asked.
9525
+ result = entry["produce"]({**cfg, **{k: request[k] for k in ("url", "method")
9526
+ if isinstance(request.get(k), str)}})
9527
+ if result.get("refused"):
9528
+ return None, result["refused"]
9529
+ if result.get("content") is None:
9530
+ return None, (502, f"the live fetch of {name} produced nothing: "
9531
+ + (result.get("error") or "unknown"))
9532
+ kept, take_err = entry["take"](name, result["content"])
9533
+ if take_err:
9534
+ return None, take_err
9535
+ out[name] = kept.decode("utf-8")
9536
+ continue
9537
+ path = SOURCES.file_path(sid, name)
9538
+ if path is None or not path.is_file():
9539
+ return None, (400, f"{name!r} is not in that Source")
9540
+ try:
9541
+ out[name] = path.read_text("utf-8")
9542
+ except (OSError, UnicodeDecodeError):
9543
+ return None, (400, f"{name} is not text")
9544
+ return out, None
9545
+
9047
9546
  @staticmethod
9048
9547
  def _produce_live(sid, entry, cfg):
9049
9548
  """Produce a live dynamic Source's item now, for a run being queued:
@@ -9412,14 +9911,17 @@ class Handler(BaseHTTPRequestHandler):
9412
9911
  parts = path.split("/")
9413
9912
  if len(parts) == 3:
9414
9913
  sources = SOURCES.list()
9415
- return self._json(200, {"sources": sources, "cap": MAX_SOURCE,
9416
- "used": sum(s["bytes"] for s in sources if not s["system"])})
9914
+ return self._json(200, {"sources": sources, "cap": MAX_SOURCE})
9417
9915
  if len(parts) == 4:
9418
9916
  source = SOURCES.get(parts[3])
9419
9917
  return self._json(200, source) if source else self._send(404, b"not found", "text/plain")
9420
9918
  if len(parts) == 5 and parts[4] == "definition":
9421
9919
  got = SOURCES.definition(parts[3])
9422
9920
  return self._json(200, got) if got else self._send(404, b"not found", "text/plain")
9921
+ if len(parts) == 5 and parts[4] == "responses":
9922
+ held = SOURCES.responses(parts[3])
9923
+ return self._json(200, {"responses": held}) if held is not None \
9924
+ else self._send(404, b"not found", "text/plain")
9423
9925
  if len(parts) == 5 and parts[4] == "calls":
9424
9926
  calls = SOURCES.calls(parts[3])
9425
9927
  return self._json(200, {"calls": calls}) if calls is not None \
@@ -9495,7 +9997,17 @@ class Handler(BaseHTTPRequestHandler):
9495
9997
  if err:
9496
9998
  code, message = err
9497
9999
  return self._json(code, {"error": message})
9498
- return self._json(201, {"call": call})
10000
+ return self._json(201 if call["outcome"] == "added" else 200, {"call": call})
10001
+ if len(parts) == 6 and parts[4] == "responses" and parts[5] == "restore":
10002
+ payload = self._payload() or {}
10003
+ name = source_name(payload.get("name"))
10004
+ n, drop = payload.get("n"), payload.get("drop")
10005
+ if name is None or type(n) is not int or not (drop is None or type(drop) is int):
10006
+ return self._json(400, {"error": "a restore is { name, n, drop? }"})
10007
+ got, err = SOURCES.restore_response(parts[3], name, n, drop)
10008
+ if err:
10009
+ return self._json(err[0], {"error": err[1]})
10010
+ return self._json(200, {"call": got})
9499
10011
  if len(parts) == 6 and parts[4] == "calls" and parts[5] == "remove":
9500
10012
  names = (self._payload() or {}).get("names")
9501
10013
  if not isinstance(names, list) or not names:
@@ -9588,12 +10100,21 @@ class Handler(BaseHTTPRequestHandler):
9588
10100
  if len(body) != length:
9589
10101
  return self._json(400, {"error": "the body ended before Content-Length promised"})
9590
10102
  # A type that checks what it takes (a flow's records) has it first.
9591
- take = (SOURCES.type_of(sid) or {}).get("take")
10103
+ entry = SOURCES.type_of(sid) or {}
10104
+ take = entry.get("take")
9592
10105
  if take is not None:
9593
10106
  body, err = take(name, body)
9594
10107
  if err:
9595
10108
  code, message = err
9596
10109
  return self._json(code, {"error": message})
10110
+ # One a kind already holds by its key (a record of the same run's call,
10111
+ # edited or not) is not kept twice (#587).
10112
+ key = entry.get("key")
10113
+ value = key(body) if key is not None else None
10114
+ if value is not None:
10115
+ held = SOURCES.key_held(sid, key, value)
10116
+ if held is not None:
10117
+ return self._json(200, {"source": sid, "name": held, "unchanged": True})
9597
10118
  stored, err = SOURCES.add_file(sid, name, body)
9598
10119
  if err:
9599
10120
  code, message = err
@@ -9688,11 +10209,16 @@ class Handler(BaseHTTPRequestHandler):
9688
10209
  # key that comes is the connection's from then on, a rotated one included.
9689
10210
  # A connection carries its share set (`shares`, workspace slugs and the
9690
10211
  # sentinels; absent is All) from version 2: version 1 carried none, so an
9691
- # import reads its connections as All.
10212
+ # import reads its connections as All. Version 3 carries (Default)'s settings
10213
+ # (`connectionProfileDefault`, #603), which the profiles store keeps beside
10214
+ # its list; an import sets them only on a lab whose (Default) sets nothing.
9692
10215
 
9693
10216
  LAB_FORMAT = "evals-lab/lab"
9694
- LAB_VERSIONS = (1, 2)
9695
- LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "grader", "workspaces")
10217
+ LAB_VERSIONS = (1, 2, 3)
10218
+ LAB_FIELDS = ("format", "version", "exportedAt", "connections", "connectionProfiles", "connectionProfileDefault",
10219
+ "grader", "workspaces")
10220
+ # What a Connection Profile holds that is not a setting, so (Default) holds none.
10221
+ PROFILE_META = ("id", "name", "connection", "fromProfile")
9696
10222
 
9697
10223
 
9698
10224
  def lab_export(only=None) -> dict:
@@ -9702,8 +10228,10 @@ def lab_export(only=None) -> dict:
9702
10228
  the profiles over them (or over none) and the grader if it is one of them,
9703
10229
  each share set narrowed to that workspace, the one the file names, and
9704
10230
  *new kept where the connection has it. A connection shared only with
9705
- workspaces in the trash is usable nowhere, and an empty share set would
9706
- import as All, so it is left out, and out as the grader too."""
10231
+ workspaces in the trash, or with none, is usable nowhere, and an empty share set would
10232
+ import as All, so it is left out, and out as the grader too. So is a
10233
+ connection of a local type (Echo): a target step stands in for it, and the
10234
+ lab it lands in offers that step already."""
9707
10235
  body = (STORE.all().get("promptlab.connections") or {}).get("body") or {}
9708
10236
  profiles = (STORE.all().get("promptlab.connectionProfiles") or {}).get("body") or {}
9709
10237
  every = WORKSPACES.list()
@@ -9716,6 +10244,9 @@ def lab_export(only=None) -> dict:
9716
10244
  for c in body.get("list") or []:
9717
10245
  if not isinstance(c, dict) or (only and not STORE.shared("profile", str(c.get("id") or ""), spaces[0]["id"])):
9718
10246
  continue
10247
+ if c.get("type") in LOCAL_CONNECTIONS:
10248
+ nowhere.add(c.get("id"))
10249
+ continue
9719
10250
  entry = {k: v for k, v in c.items() if k not in ("key", "shares")}
9720
10251
  held = rows.get(f"profile:{c.get('id')}")
9721
10252
  if held and SHARE_ALL not in held:
@@ -9738,6 +10269,9 @@ def lab_export(only=None) -> dict:
9738
10269
  if (isinstance(body.get("grader"), str) and body["grader"] and body["grader"] not in nowhere
9739
10270
  and (not only or body["grader"] in kept)):
9740
10271
  out["grader"] = body["grader"]
10272
+ # (Default) answers on any connection, so every workspace's file has it.
10273
+ if isinstance(profiles.get("default"), dict) and profiles["default"]:
10274
+ out["connectionProfileDefault"] = profiles["default"]
9741
10275
  return out
9742
10276
 
9743
10277
 
@@ -9794,6 +10328,19 @@ def lab_problems(doc) -> str:
9794
10328
  why = _keyless(p, what)
9795
10329
  if why:
9796
10330
  return why
10331
+ if "connectionProfileDefault" in doc:
10332
+ d = doc["connectionProfileDefault"]
10333
+ if doc["version"] < 3:
10334
+ return f"a lab file of version {doc['version']} holds no connectionProfileDefault"
10335
+ if not isinstance(d, dict):
10336
+ return "the connection profile (Default) is an object of settings"
10337
+ bad = next((k for k, v in d.items() if k in PROFILE_META or not isinstance(v, str)), None)
10338
+ if bad:
10339
+ return f"the connection profile (Default)'s {bad} is not a setting"
10340
+ # A profile's key is its connection's, and (Default) names none.
10341
+ held = next((k for k, v in d.items() if LOOKS_LIKE_A_KEY.match(k) and v), None)
10342
+ if held:
10343
+ return f"the connection profile (Default) holds a {held}; a lab file holds no key"
9797
10344
  if "grader" in doc and not isinstance(doc["grader"], str):
9798
10345
  return "the grader is not a connection's id"
9799
10346
  seen = set()
@@ -9827,6 +10374,11 @@ def lab_problems(doc) -> str:
9827
10374
  if not c["shares"]:
9828
10375
  return (f"connection {c['id']}'s shares is empty; leave shares out for All, "
9829
10376
  "or name a workspace or *new")
10377
+ # *none is the store's own mark for a set that lost its last workspace;
10378
+ # an export leaves such a connection out, so a file never carries it.
10379
+ if SHARE_NONE in c["shares"]:
10380
+ return (f"connection {c['id']}'s shares names *none; a lab file holds no connection "
10381
+ "shared with no workspace -- leave it out, or name a workspace or *new")
9830
10382
  lost = [s for s in c["shares"] if s not in (SHARE_ALL, SHARE_NEW) and s not in known]
9831
10383
  if lost:
9832
10384
  return f"connection {c['id']} is shared with {', '.join(lost)}, which neither the file nor this lab has"
@@ -9887,15 +10439,17 @@ def lab_import(doc, keys) -> tuple:
9887
10439
  profs.append(dict(p))
9888
10440
  pchanged = True
9889
10441
  lines.append(f"connection profile {p['name']} added")
10442
+ if doc.get("connectionProfileDefault") and not pbody.get("default"):
10443
+ pbody["default"] = dict(doc["connectionProfileDefault"])
10444
+ pchanged = True
10445
+ lines.append("connection profile (Default) set")
9890
10446
  if doc.get("grader") and not body.get("grader"):
9891
10447
  body["grader"] = doc["grader"]
9892
10448
  changed = True
9893
10449
  lines.append(f"grader set to {doc['grader']}")
9894
10450
  # Only an added connection takes the file's share set: one the lab had
9895
10451
  # keeps its own, as everything else it had does. Set before the write,
9896
- # so the connection is never there and shared with All in between; a
9897
- # failed write puts back what the rows were, since its ids never landed.
9898
- before = STORE.shares()
10452
+ # so the connection is never there and shared with All in between.
9899
10453
  for c in added:
9900
10454
  if "shares" in c:
9901
10455
  STORE.set_shares("profile", c["id"], [s if s in (SHARE_ALL, SHARE_NEW) else wid_of[s]
@@ -9912,9 +10466,15 @@ def lab_import(doc, keys) -> tuple:
9912
10466
  ok, _ = STORE.write(writes, STORE.default_ws)
9913
10467
  if ok:
9914
10468
  break
10469
+ # A failed write takes back the rows of each id that still never
10470
+ # landed, as no rows at all (an empty set is a delete, never *none).
10471
+ # One another writer landed in between keeps the rows it has now:
10472
+ # taking them would leave it shared with All.
10473
+ landed = {x.get("id") for x in ((STORE.all().get("promptlab.connections") or {}).get("body")
10474
+ or {}).get("list") or [] if isinstance(x, dict)}
9915
10475
  for c in added:
9916
- if "shares" in c:
9917
- STORE.set_shares("profile", c["id"], before.get(f"profile:{c['id']}") or [])
10476
+ if "shares" in c and c["id"] not in landed:
10477
+ STORE.set_shares("profile", c["id"], [])
9918
10478
  else:
9919
10479
  return None, (409, "the lab's connections kept changing while they were imported; import again")
9920
10480
  said += lines