evals-lab 0.6.0 → 0.8.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.
- package/CHANGELOG.md +108 -0
- package/bin/run.js +36 -5
- package/lab/VERSION +1 -1
- package/lab/evals-core.mjs +464 -75
- package/lab/flows/flowApi.mjs +153 -0
- package/lab/flows/record.mjs +495 -0
- package/lab/metrics/builtin.mjs +113 -52
- package/lab/run-evals.js +32 -8
- package/lab/server.py +1174 -179
- package/lab/web/dist/assets/gallery-SnUhXRBn.js +3 -0
- package/lab/web/dist/assets/main-Ca7o-nM0.css +1 -0
- package/lab/web/dist/assets/main-Dru4_P5G.js +20 -0
- package/lab/web/dist/assets/tokens-0az9gfTq.js +58 -0
- package/lab/web/dist/assets/tokens-CqWJKhOx.css +1 -0
- package/lab/web/dist/gallery.html +3 -3
- package/lab/web/dist/index.html +4 -4
- package/package.json +1 -1
- package/lab/web/dist/assets/gallery-D_MxkfLj.js +0 -3
- package/lab/web/dist/assets/main-DDoeU6hq.css +0 -1
- package/lab/web/dist/assets/main-nz6Q4jVm.js +0 -21
- package/lab/web/dist/assets/tokens-Bl8cOqkf.js +0 -59
- package/lab/web/dist/assets/tokens-DCHW9ru7.css +0 -1
package/lab/server.py
CHANGED
|
@@ -298,23 +298,53 @@ FILE_TYPES = {
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
# The kinds of Source, mirrored from evals-core.ts's SOURCE_TYPES: Python
|
|
301
|
-
# cannot load TypeScript, so the server keeps what it enforces
|
|
302
|
-
#
|
|
303
|
-
#
|
|
301
|
+
# cannot load TypeScript, so the server keeps what it enforces. A kind is a
|
|
302
|
+
# registry entry, never a branch on its id. The row's `type` is the kind; a
|
|
303
|
+
# workflow kind's enforcement comes from the platform its `config.platform`
|
|
304
|
+
# names (WORKFLOW_PLATFORMS below), a kind marked `platforms`.
|
|
304
305
|
SOURCE_TYPES = {
|
|
305
306
|
"files": {"label": "File Library", "uploads": FILE_TYPES},
|
|
306
|
-
|
|
307
|
-
# its records arrive with a later phase -- and a definition, kept as a
|
|
308
|
-
# versioned snapshot beside the row.
|
|
309
|
-
#
|
|
310
|
-
# `uploads` are the files it takes; `take`, when there is one, checks and
|
|
311
|
-
# rewrites each before it is kept; `signIn` is the sign-in it is made with,
|
|
312
|
-
# without which the lab cannot make one; `definition` means it keeps one.
|
|
313
|
-
"power-automate": {"label": "Power Automate workflow", "definition": True, "signIn": "microsoft",
|
|
314
|
-
"uploads": {".json": "application/json; charset=utf-8"}},
|
|
307
|
+
"workflow": {"label": "Workflow", "platforms": True},
|
|
315
308
|
}
|
|
316
309
|
DEFAULT_SOURCE_TYPE = "files"
|
|
317
310
|
|
|
311
|
+
# The workflow platforms, mirrored from evals-core.ts's WORKFLOW_PLATFORMS: the
|
|
312
|
+
# flow engine a workflow Source speaks to, named in its `config.platform`. A
|
|
313
|
+
# workflow kind's enforcement is the platform's, not the kind's.
|
|
314
|
+
#
|
|
315
|
+
# `uploads` are the files it takes; `take`, when there is one, checks and
|
|
316
|
+
# rewrites each before it is kept; `signIn` is the sign-in it is made with,
|
|
317
|
+
# without which the lab cannot make one; `definition` means it keeps one.
|
|
318
|
+
WORKFLOW_PLATFORMS = {
|
|
319
|
+
"power-automate": {"label": "Power Automate flow", "definition": True, "signIn": "microsoft",
|
|
320
|
+
"uploads": {".json": "application/json; charset=utf-8"}},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _config_platform(config):
|
|
325
|
+
"""The platform a Source's stored config (a JSON string or None) names,
|
|
326
|
+
or None -- what the page labels a workflow by."""
|
|
327
|
+
try:
|
|
328
|
+
cfg = json.loads(config) if config else None
|
|
329
|
+
except ValueError:
|
|
330
|
+
return None
|
|
331
|
+
platform = cfg.get("platform") if isinstance(cfg, dict) else None
|
|
332
|
+
return platform if isinstance(platform, str) else None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def source_entry(ptype, config):
|
|
336
|
+
"""The enforcement entry for a Source of type [ptype] and [config] (a
|
|
337
|
+
dict or None): its kind's, or, for a workflow kind, the platform its
|
|
338
|
+
config names. None for an unknown kind, or a workflow whose platform the
|
|
339
|
+
lab does not know. A reader asks the entry, never the id."""
|
|
340
|
+
kind = SOURCE_TYPES.get(ptype)
|
|
341
|
+
if kind is None:
|
|
342
|
+
return None
|
|
343
|
+
if kind.get("platforms"):
|
|
344
|
+
platform = config.get("platform") if isinstance(config, dict) else None
|
|
345
|
+
return WORKFLOW_PLATFORMS.get(platform)
|
|
346
|
+
return kind
|
|
347
|
+
|
|
318
348
|
# The Entra app the page signs in to Microsoft 365 with (docs/power-automate.md
|
|
319
349
|
# § Registering the app). A single-page app has no secret, so both are public
|
|
320
350
|
# and served to the page; the token it gets stays in the browser, and the
|
|
@@ -481,7 +511,14 @@ def take_record(name, data):
|
|
|
481
511
|
return json.dumps(kept, indent=2).encode("utf-8"), None
|
|
482
512
|
|
|
483
513
|
|
|
484
|
-
|
|
514
|
+
WORKFLOW_PLATFORMS["power-automate"]["take"] = take_record
|
|
515
|
+
|
|
516
|
+
# The lab's own workflow platforms and wizards, kept apart so the mirror can be
|
|
517
|
+
# rebuilt from them as plugins come and go (Plugins.apply), and so a plugin is
|
|
518
|
+
# refused the id of one the lab ships (#303). Wizards are a page concept; the
|
|
519
|
+
# server holds only their ids, to refuse two plugins claiming one.
|
|
520
|
+
BUILTIN_WORKFLOW_PLATFORMS = {k: dict(v) for k, v in WORKFLOW_PLATFORMS.items()}
|
|
521
|
+
BUILTIN_WIZARD_IDS = {"test-workflow"}
|
|
485
522
|
|
|
486
523
|
# The sign-ins a Source type may be made with, and whether this lab has each:
|
|
487
524
|
# a type naming one this lab lacks cannot be made.
|
|
@@ -602,6 +639,45 @@ def hide_keys(docs: dict) -> dict:
|
|
|
602
639
|
return out
|
|
603
640
|
|
|
604
641
|
|
|
642
|
+
# ---- workspaces (docs/workspaces.md) -------------------------------------
|
|
643
|
+
# A workspace is the lab's first tenant dimension: a row in `workspaces` and a
|
|
644
|
+
# filter on every scopable table. Phase 1 is invisible -- a request that names
|
|
645
|
+
# no workspace resolves to the flagged default, so the existing page and CI
|
|
646
|
+
# keep working. It is isolation, not access control: until multi-user lands, a
|
|
647
|
+
# workspace is a filter, not a permission boundary (AGENTS.md).
|
|
648
|
+
#
|
|
649
|
+
# The workspace the current thread's db work is scoped to is held here, bound
|
|
650
|
+
# per request by the Handler and per run by the queue worker; unset, a scoped
|
|
651
|
+
# read falls back to the store's flagged default, which keeps direct callers
|
|
652
|
+
# (startup, a check's own calls) on the default workspace.
|
|
653
|
+
_WS = threading.local()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def set_workspace(ws):
|
|
657
|
+
"""Bind the current thread to workspace `ws` for its db work; None clears
|
|
658
|
+
it, so scoped reads fall back to the store's default workspace."""
|
|
659
|
+
_WS.ws = ws
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def workspace_of(store) -> str:
|
|
663
|
+
ws = getattr(_WS, "ws", None)
|
|
664
|
+
return ws if ws else store.default_ws
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# The `connection_shares` sentinels (phase 4 fills and enforces the table;
|
|
668
|
+
# phase 1 only creates it empty): every workspace, and future ones.
|
|
669
|
+
SHARE_ALL = "*all"
|
|
670
|
+
SHARE_NEW = "*new"
|
|
671
|
+
|
|
672
|
+
# How the `docs` table scopes by key (docs/workspaces.md): workflows are
|
|
673
|
+
# per-workspace, profiles/tokens global, and promptlab.versions is split --
|
|
674
|
+
# its pipeline/dataset slices per-workspace, its profile slice global, because
|
|
675
|
+
# profiles are global so their Restore must be too. served()/write() route
|
|
676
|
+
# each key by these; any other key is per-workspace.
|
|
677
|
+
DOC_GLOBAL = ("promptlab.profiles", "promptlab.tokens")
|
|
678
|
+
DOC_SPLIT = "promptlab.versions"
|
|
679
|
+
|
|
680
|
+
|
|
605
681
|
class Store:
|
|
606
682
|
"""
|
|
607
683
|
Documents by name, each with a version that goes up by one per write.
|
|
@@ -618,17 +694,36 @@ class Store:
|
|
|
618
694
|
self.path = path
|
|
619
695
|
self.lock = threading.Lock()
|
|
620
696
|
with self.lock, closing(sqlite3.connect(path)) as db, db:
|
|
621
|
-
|
|
622
|
-
|
|
697
|
+
# The workspace registry and the share table come first:
|
|
698
|
+
# everything else is scoped to a workspace, and the flagged default
|
|
699
|
+
# must exist before any backfill can assign rows to it.
|
|
700
|
+
db.execute("CREATE TABLE IF NOT EXISTS workspaces ("
|
|
701
|
+
"id TEXT PRIMARY KEY, slug TEXT UNIQUE, name TEXT NOT NULL, "
|
|
702
|
+
"archived INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0, "
|
|
703
|
+
"last_used TEXT, created_at TEXT, trash TEXT, trashed_at REAL)")
|
|
704
|
+
# Shared-by-choice connections (profiles, Accounts): phase 4 fills
|
|
705
|
+
# and enforces this; phase 1 only creates it empty.
|
|
706
|
+
db.execute("CREATE TABLE IF NOT EXISTS connection_shares ("
|
|
707
|
+
"kind TEXT NOT NULL, id TEXT NOT NULL, workspace TEXT NOT NULL, "
|
|
708
|
+
"PRIMARY KEY (kind, id, workspace))")
|
|
709
|
+
self.default_ws = self._ensure_default(db)
|
|
623
710
|
db.execute("CREATE TABLE IF NOT EXISTS runs (at TEXT PRIMARY KEY, body TEXT NOT NULL)")
|
|
624
711
|
# The key of a profile a write took out, by id, for TRASH_SECONDS:
|
|
625
712
|
# Undo puts the profile back holding KEY_HELD, and this is what
|
|
626
|
-
# it holds.
|
|
713
|
+
# it holds. Profiles are global, so the ring is too.
|
|
627
714
|
db.execute("CREATE TABLE IF NOT EXISTS dropped_keys (id TEXT PRIMARY KEY, "
|
|
628
715
|
"key TEXT NOT NULL, at REAL NOT NULL)")
|
|
716
|
+
# The docs table keys documents by (name, workspace): a store from
|
|
717
|
+
# before workspaces keyed them by name alone and is rebuilt once,
|
|
718
|
+
# the global keys moved under NULL and promptlab.versions split.
|
|
719
|
+
# Idempotent: a later open finds the workspace column and leaves it.
|
|
720
|
+
cols = {r[1] for r in db.execute("PRAGMA table_info(docs)")}
|
|
721
|
+
if not cols:
|
|
722
|
+
self._create_docs(db)
|
|
629
723
|
# History was a document, capped at what a browser could hold. The
|
|
630
724
|
# first start with the table moves what that document had into it,
|
|
631
|
-
# once, and drops the document so the page stops carrying it.
|
|
725
|
+
# once, and drops the document so the page stops carrying it. It
|
|
726
|
+
# reads name/body, so it runs on either docs schema.
|
|
632
727
|
old = db.execute("SELECT body FROM docs WHERE name = 'promptlab.runs'").fetchone()
|
|
633
728
|
if old and not db.execute("SELECT 1 FROM runs LIMIT 1").fetchone():
|
|
634
729
|
for run in json.loads(old[0] or "null") or []:
|
|
@@ -636,20 +731,118 @@ class Store:
|
|
|
636
731
|
db.execute("INSERT OR IGNORE INTO runs (at, body) VALUES (?, ?)",
|
|
637
732
|
(run["at"], json.dumps(run)))
|
|
638
733
|
db.execute("DELETE FROM docs WHERE name = 'promptlab.runs'")
|
|
734
|
+
# The run history is the queue table now; this `runs` table holds
|
|
735
|
+
# only what the pre-queue history document migrated into it, and is
|
|
736
|
+
# served from nowhere. It carries the workspace column all the same,
|
|
737
|
+
# so the scopable-table set is whole and anything that ever reads it
|
|
738
|
+
# inherits the filter (docs/workspaces.md).
|
|
739
|
+
if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(runs)")}:
|
|
740
|
+
db.execute("ALTER TABLE runs ADD COLUMN workspace TEXT")
|
|
741
|
+
db.execute("UPDATE runs SET workspace = ? WHERE workspace IS NULL", (self.default_ws,))
|
|
742
|
+
if cols and "workspace" not in cols:
|
|
743
|
+
self._rebuild_docs(db)
|
|
744
|
+
|
|
745
|
+
def _ensure_default(self, db) -> str:
|
|
746
|
+
"""The flagged default workspace's id, seeding "Default" (/w/default,
|
|
747
|
+
is_default = 1) on a store that has none. Defined by the flag, not its
|
|
748
|
+
name or slug, so a later rename never moves it (docs/workspaces.md)."""
|
|
749
|
+
row = db.execute("SELECT id FROM workspaces WHERE is_default = 1").fetchone()
|
|
750
|
+
if row:
|
|
751
|
+
return row[0]
|
|
752
|
+
wid = secrets.token_hex(6)
|
|
753
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
754
|
+
db.execute("INSERT INTO workspaces (id, slug, name, archived, is_default, last_used, created_at) "
|
|
755
|
+
"VALUES (?, 'default', 'Default', 0, 1, ?, ?)", (wid, now, now))
|
|
756
|
+
return wid
|
|
757
|
+
|
|
758
|
+
def workspace(self, slug: str) -> str:
|
|
759
|
+
"""The id of the workspace at /w/<slug>, or None. An unknown slug is
|
|
760
|
+
None; the Handler falls back to the default so a stale address still
|
|
761
|
+
reaches a working lab until the page's Gone state lands (phase 3)."""
|
|
762
|
+
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
763
|
+
row = db.execute("SELECT id FROM workspaces WHERE slug = ?", (slug,)).fetchone()
|
|
764
|
+
return row[0] if row else None
|
|
639
765
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
766
|
+
@staticmethod
|
|
767
|
+
def _create_docs(db):
|
|
768
|
+
db.execute("CREATE TABLE docs (name TEXT NOT NULL, workspace TEXT, "
|
|
769
|
+
"version INTEGER NOT NULL, body TEXT, updated_at TEXT NOT NULL)")
|
|
770
|
+
# NULLs are distinct in a UNIQUE index, so the global keys cannot rely
|
|
771
|
+
# on one PRIMARY KEY for their one-row-per-name rule: two partial
|
|
772
|
+
# indexes, scoped rows keyed by (name, workspace) and global rows by
|
|
773
|
+
# name, give each its own uniqueness and its own upsert target.
|
|
774
|
+
db.execute("CREATE UNIQUE INDEX docs_scoped ON docs(name, workspace) WHERE workspace IS NOT NULL")
|
|
775
|
+
db.execute("CREATE UNIQUE INDEX docs_global ON docs(name) WHERE workspace IS NULL")
|
|
776
|
+
|
|
777
|
+
def _rebuild_docs(self, db):
|
|
778
|
+
rows = db.execute("SELECT name, version, body, updated_at FROM docs").fetchall()
|
|
779
|
+
db.execute("ALTER TABLE docs RENAME TO docs_old")
|
|
780
|
+
self._create_docs(db)
|
|
781
|
+
put = lambda name, ws, version, body, at: db.execute(
|
|
782
|
+
"INSERT INTO docs (name, workspace, version, body, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
783
|
+
(name, ws, version, body, at))
|
|
784
|
+
for name, version, body, at in rows:
|
|
785
|
+
if name in DOC_GLOBAL:
|
|
786
|
+
put(name, None, version, body, at)
|
|
787
|
+
elif name == DOC_SPLIT:
|
|
788
|
+
parsed = json.loads(body) if body else {}
|
|
789
|
+
put(name, self.default_ws, version, None if body is None else json.dumps(
|
|
790
|
+
{"pipeline": parsed.get("pipeline", {}), "dataset": parsed.get("dataset", {})}), at)
|
|
791
|
+
put(name, None, version, None if body is None else json.dumps(
|
|
792
|
+
{"profile": parsed.get("profile", {})}), at)
|
|
793
|
+
else:
|
|
794
|
+
put(name, self.default_ws, version, body, at)
|
|
795
|
+
db.execute("DROP TABLE docs_old")
|
|
796
|
+
|
|
797
|
+
def _doc_rows(self, db, ws: str) -> dict:
|
|
798
|
+
"""The logical document set a workspace reads: its scoped rows, the
|
|
799
|
+
global rows (profiles, tokens), and promptlab.versions reassembled from
|
|
800
|
+
its per-workspace pipeline/dataset slice and the global profile slice,
|
|
801
|
+
carrying the per-workspace slice's version so the page's one version
|
|
802
|
+
per key still arbitrates pipeline/dataset Restore."""
|
|
803
|
+
parse = lambda b: None if b is None else json.loads(b)
|
|
804
|
+
scoped = {name: (version, body) for name, version, body in db.execute(
|
|
805
|
+
"SELECT name, version, body FROM docs WHERE workspace = ?", (ws,))}
|
|
806
|
+
glob = {name: (version, body) for name, version, body in db.execute(
|
|
807
|
+
"SELECT name, version, body FROM docs WHERE workspace IS NULL")}
|
|
808
|
+
out = {name: {"version": v, "body": parse(b)}
|
|
809
|
+
for name, (v, b) in scoped.items() if name != DOC_SPLIT}
|
|
810
|
+
for name in DOC_GLOBAL:
|
|
811
|
+
if name in glob:
|
|
812
|
+
v, b = glob[name]
|
|
813
|
+
out[name] = {"version": v, "body": parse(b)}
|
|
814
|
+
sv, gv = scoped.get(DOC_SPLIT), glob.get(DOC_SPLIT)
|
|
815
|
+
if sv is not None or gv is not None:
|
|
816
|
+
sver, sbody = sv if sv is not None else (0, None)
|
|
817
|
+
sbody, gbody = parse(sbody) or {}, parse(gv[1]) if gv is not None else {}
|
|
818
|
+
out[DOC_SPLIT] = {"version": sver, "body": {
|
|
819
|
+
"pipeline": sbody.get("pipeline", {}), "dataset": sbody.get("dataset", {}),
|
|
820
|
+
"profile": (gbody or {}).get("profile", {})}}
|
|
821
|
+
return out
|
|
643
822
|
|
|
644
|
-
def all(self) -> dict:
|
|
823
|
+
def all(self, ws=None) -> dict:
|
|
645
824
|
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
646
|
-
return self.
|
|
647
|
-
|
|
648
|
-
def served(self) -> dict:
|
|
649
|
-
"""What a browser is handed: the SYNCED documents
|
|
650
|
-
key's rows stay in the store without reaching a page
|
|
651
|
-
profile's key (KEY_HELD)."""
|
|
652
|
-
return hide_keys({n: d for n, d in self.all().items() if n in SYNCED})
|
|
825
|
+
return self._doc_rows(db, ws or workspace_of(self))
|
|
826
|
+
|
|
827
|
+
def served(self, ws=None) -> dict:
|
|
828
|
+
"""What a browser is handed for its workspace: the SYNCED documents
|
|
829
|
+
only, so a retired key's rows stay in the store without reaching a page
|
|
830
|
+
again, and no profile's key (KEY_HELD)."""
|
|
831
|
+
return hide_keys({n: d for n, d in self.all(ws).items() if n in SYNCED})
|
|
832
|
+
|
|
833
|
+
def _put(self, db, name, ws, version, body, at):
|
|
834
|
+
text = None if body is None else json.dumps(body)
|
|
835
|
+
if ws is None:
|
|
836
|
+
db.execute(
|
|
837
|
+
"INSERT INTO docs (name, workspace, version, body, updated_at) VALUES (?, NULL, ?, ?, ?) "
|
|
838
|
+
"ON CONFLICT(name) WHERE workspace IS NULL DO UPDATE SET version = excluded.version, "
|
|
839
|
+
"body = excluded.body, updated_at = excluded.updated_at", (name, version, text, at))
|
|
840
|
+
else:
|
|
841
|
+
db.execute(
|
|
842
|
+
"INSERT INTO docs (name, workspace, version, body, updated_at) VALUES (?, ?, ?, ?, ?) "
|
|
843
|
+
"ON CONFLICT(name, workspace) WHERE workspace IS NOT NULL DO UPDATE SET "
|
|
844
|
+
"version = excluded.version, body = excluded.body, updated_at = excluded.updated_at",
|
|
845
|
+
(name, ws, version, text, at))
|
|
653
846
|
|
|
654
847
|
def _keyring(self, db, now: dict) -> dict:
|
|
655
848
|
"""Each profile id's key as the store holds it: its profile's, else a
|
|
@@ -668,18 +861,24 @@ class Store:
|
|
|
668
861
|
return ring
|
|
669
862
|
|
|
670
863
|
def held_key(self, key: str) -> str:
|
|
671
|
-
"""The key KEY_HELD stands for, or "" when the store holds none.
|
|
864
|
+
"""The key KEY_HELD stands for, or "" when the store holds none.
|
|
865
|
+
Profiles are global, so the ring is read under the default workspace."""
|
|
672
866
|
with self.lock, closing(sqlite3.connect(self.path)) as db, db:
|
|
673
|
-
return self._keyring(db, self.
|
|
867
|
+
return self._keyring(db, self._doc_rows(db, self.default_ws)).get(key[len(KEY_HELD):], "")
|
|
674
868
|
|
|
675
|
-
def write(self, docs: dict):
|
|
869
|
+
def write(self, docs: dict, ws=None):
|
|
676
870
|
"""
|
|
677
|
-
`docs` is {name: {"version": the version it began from, "body": ...}}
|
|
678
|
-
|
|
679
|
-
|
|
871
|
+
`docs` is {name: {"version": the version it began from, "body": ...}},
|
|
872
|
+
written into workspace `ws` (the current thread's, by default). Each
|
|
873
|
+
key is routed by scope: workflows and the rest per-workspace, profiles
|
|
874
|
+
and tokens global, promptlab.versions split (pipeline/dataset
|
|
875
|
+
per-workspace, profile global). Returns ({name: new version}, None), or
|
|
876
|
+
(None, {name: current}) for every document that has moved on, with
|
|
877
|
+
nothing written.
|
|
680
878
|
"""
|
|
879
|
+
ws = ws or workspace_of(self)
|
|
681
880
|
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
682
|
-
now = self.
|
|
881
|
+
now = self._doc_rows(db, ws)
|
|
683
882
|
have = lambda n: now.get(n, {"version": 0, "body": None})
|
|
684
883
|
stale = {n: have(n) for n, d in docs.items() if d["version"] != have(n)["version"]}
|
|
685
884
|
if stale:
|
|
@@ -701,13 +900,372 @@ class Store:
|
|
|
701
900
|
for p in profiles_in("promptlab.profiles", have("promptlab.profiles")["body"])
|
|
702
901
|
if str(p.get("id") or "") not in kept and isinstance(p.get("key"), str) and p["key"]])
|
|
703
902
|
for n, d in docs.items():
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
903
|
+
version, body = have(n)["version"] + 1, d["body"]
|
|
904
|
+
if n == DOC_SPLIT:
|
|
905
|
+
parsed = body if isinstance(body, dict) else {}
|
|
906
|
+
self._put(db, n, ws, version, None if body is None else {
|
|
907
|
+
"pipeline": parsed.get("pipeline", {}), "dataset": parsed.get("dataset", {})}, at)
|
|
908
|
+
# The profile slice is global, on its own version
|
|
909
|
+
# counter: profiles are shared, so their Restore is too.
|
|
910
|
+
gv = db.execute("SELECT version FROM docs WHERE name = ? AND workspace IS NULL",
|
|
911
|
+
(n,)).fetchone()
|
|
912
|
+
self._put(db, n, None, (gv[0] if gv else 0) + 1,
|
|
913
|
+
None if body is None else {"profile": parsed.get("profile", {})}, at)
|
|
914
|
+
else:
|
|
915
|
+
self._put(db, n, None if n in DOC_GLOBAL else ws, version, body, at)
|
|
709
916
|
return {n: have(n)["version"] + 1 for n in docs}, None
|
|
710
917
|
|
|
918
|
+
# ---- shared-by-choice connections (docs/workspaces.md) ------------------
|
|
919
|
+
# A Target profile or an Account (kind "profile"/"account", id the
|
|
920
|
+
# profile's or the account's) is usable in a workspace by its share set in
|
|
921
|
+
# `connection_shares`: a row for that workspace, or the SHARE_ALL sentinel.
|
|
922
|
+
# No row at all is the migration and new-connection default -- shared with
|
|
923
|
+
# every workspace, so nothing stops running the moment workspaces exist
|
|
924
|
+
# (open question 2, resolved All); narrowing a connection is adding the
|
|
925
|
+
# rows that say where it may be used. SHARE_NEW is never read here: a
|
|
926
|
+
# workspace created after a SHARE_NEW share was set has it materialised into
|
|
927
|
+
# a concrete row at creation (Workspaces.create), so "New workspaces" is
|
|
928
|
+
# exactly the future ones and not the ones that already existed. The server
|
|
929
|
+
# is the only writer and the only enforcer -- the key a share gates is
|
|
930
|
+
# never served (#258), so neither the page nor the worker could enforce it.
|
|
931
|
+
|
|
932
|
+
def shared(self, kind: str, cid: str, ws) -> bool:
|
|
933
|
+
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
934
|
+
rows = {r[0] for r in db.execute(
|
|
935
|
+
"SELECT workspace FROM connection_shares WHERE kind = ? AND id = ?", (kind, cid))}
|
|
936
|
+
return not rows or SHARE_ALL in rows or ws in rows
|
|
937
|
+
|
|
938
|
+
def shares(self) -> dict:
|
|
939
|
+
"""Every connection's share set, for the Connections menus: keyed by
|
|
940
|
+
"<kind>:<id>", the workspace values as stored (workspace ids and the
|
|
941
|
+
sentinels). A connection with no row is absent, which the page reads as
|
|
942
|
+
shared with All. No key is anywhere in this (#258)."""
|
|
943
|
+
out: dict = {}
|
|
944
|
+
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
945
|
+
for kind, cid, ws in db.execute("SELECT kind, id, workspace FROM connection_shares"):
|
|
946
|
+
out.setdefault(f"{kind}:{cid}", []).append(ws)
|
|
947
|
+
return out
|
|
948
|
+
|
|
949
|
+
def set_shares(self, kind: str, cid: str, workspaces) -> None:
|
|
950
|
+
"""Replace a connection's share set. SHARE_ALL means every workspace,
|
|
951
|
+
so it is kept alone; only the sentinels and live workspace ids are let
|
|
952
|
+
in, so a stale id cannot linger in the table."""
|
|
953
|
+
with self.lock, closing(sqlite3.connect(self.path)) as db, db:
|
|
954
|
+
valid = {r[0] for r in db.execute("SELECT id FROM workspaces WHERE trash IS NULL")}
|
|
955
|
+
chosen = [w for w in dict.fromkeys(workspaces or [])
|
|
956
|
+
if w in (SHARE_ALL, SHARE_NEW) or w in valid]
|
|
957
|
+
if SHARE_ALL in chosen:
|
|
958
|
+
chosen = [SHARE_ALL]
|
|
959
|
+
db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ?", (kind, cid))
|
|
960
|
+
db.executemany("INSERT INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
|
|
961
|
+
[(kind, cid, w) for w in chosen])
|
|
962
|
+
|
|
963
|
+
def share_with(self, kind: str, cid: str, ws, on: bool) -> None:
|
|
964
|
+
"""Add or remove one workspace from a connection's share set -- the Run
|
|
965
|
+
bar's Share link and its Undo (docs/workspaces.md). The link is offered
|
|
966
|
+
only where the connection is actually narrowed, so adding a row is what
|
|
967
|
+
grants the blocked workspace its use and Undo takes it back."""
|
|
968
|
+
with self.lock, closing(sqlite3.connect(self.path)) as db, db:
|
|
969
|
+
if on:
|
|
970
|
+
db.execute("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) "
|
|
971
|
+
"VALUES (?, ?, ?)", (kind, cid, ws))
|
|
972
|
+
else:
|
|
973
|
+
db.execute("DELETE FROM connection_shares WHERE kind = ? AND id = ? AND workspace = ?",
|
|
974
|
+
(kind, cid, ws))
|
|
975
|
+
|
|
976
|
+
def workspace_name(self, ws) -> str:
|
|
977
|
+
"""A workspace's name, for the refusal sentence the Run bar shows."""
|
|
978
|
+
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
979
|
+
row = db.execute("SELECT name FROM workspaces WHERE id = ?", (ws,)).fetchone()
|
|
980
|
+
return row[0] if row else "this workspace"
|
|
981
|
+
|
|
982
|
+
def profile_name(self, pid: str) -> str:
|
|
983
|
+
"""A Target profile's display name, by id, from the global profiles
|
|
984
|
+
document -- for the refusal sentence. The id itself when none is held."""
|
|
985
|
+
with self.lock, closing(sqlite3.connect(self.path)) as db:
|
|
986
|
+
row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.profiles' "
|
|
987
|
+
"AND workspace IS NULL").fetchone()
|
|
988
|
+
try:
|
|
989
|
+
for p in (json.loads(row[0]) or {}).get("list", []) if row and row[0] else []:
|
|
990
|
+
if isinstance(p, dict) and str(p.get("id") or "") == pid:
|
|
991
|
+
return p.get("name") or pid
|
|
992
|
+
except (ValueError, TypeError):
|
|
993
|
+
pass
|
|
994
|
+
return pid
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
# A workspace's name is one line, capped like a dataset's or a prompt's.
|
|
998
|
+
WORKSPACE_NAME_MAX = 80
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
class Workspaces:
|
|
1002
|
+
"""
|
|
1003
|
+
The workspace registry (docs/workspaces.md): the lab-wide list the
|
|
1004
|
+
Setup › Workspaces table manages. Phase 2 is the registry and its UI; the
|
|
1005
|
+
active workspace stays the flagged default (switching is phase 3), so a
|
|
1006
|
+
workspace created here is reached by its address only once that lands.
|
|
1007
|
+
|
|
1008
|
+
Create, rename, archive, unarchive, Regenerate (a new slug from the name),
|
|
1009
|
+
and a server trash like the others: delete puts a workspace into the trash
|
|
1010
|
+
with an undo window, restore brings it back, and a lazy sweep purges it
|
|
1011
|
+
after TRASH_SECONDS -- taking its scoped data with it (its pipelines, runs,
|
|
1012
|
+
datasets, prompts, Sources, packs and documents), since that is what a
|
|
1013
|
+
permanent delete means. The child tables co-scope through their parent id.
|
|
1014
|
+
|
|
1015
|
+
The slug is kept through a rename; only Regenerate changes it. The last
|
|
1016
|
+
active workspace cannot be archived, so a flag-holder always exists;
|
|
1017
|
+
archiving or deleting the flagged default moves the flag to the most
|
|
1018
|
+
recently used other active workspace (docs/workspaces.md, decision 7).
|
|
1019
|
+
"""
|
|
1020
|
+
|
|
1021
|
+
def __init__(self, store: Store):
|
|
1022
|
+
self.store = store
|
|
1023
|
+
self.dir = store.path.parent
|
|
1024
|
+
|
|
1025
|
+
def _connect(self):
|
|
1026
|
+
return closing(sqlite3.connect(self.store.path))
|
|
1027
|
+
|
|
1028
|
+
@staticmethod
|
|
1029
|
+
def _slugify(raw) -> str:
|
|
1030
|
+
s = re.sub(r"[^a-z0-9]+", "-", str(raw or "").lower()).strip("-")[:SLUG_MAX].strip("-")
|
|
1031
|
+
return s or "workspace"
|
|
1032
|
+
|
|
1033
|
+
@staticmethod
|
|
1034
|
+
def _unique(base: str, taken: set) -> str:
|
|
1035
|
+
if base not in taken:
|
|
1036
|
+
return base
|
|
1037
|
+
n = 2
|
|
1038
|
+
while True:
|
|
1039
|
+
suffix = f"-{n}"
|
|
1040
|
+
cand = (base[:SLUG_MAX - len(suffix)].strip("-") or "workspace") + suffix
|
|
1041
|
+
if cand not in taken:
|
|
1042
|
+
return cand
|
|
1043
|
+
n += 1
|
|
1044
|
+
|
|
1045
|
+
@staticmethod
|
|
1046
|
+
def _row(r, pipes, runs) -> dict:
|
|
1047
|
+
return {"id": r[0], "slug": r[1], "name": r[2], "archived": bool(r[3]),
|
|
1048
|
+
"isDefault": bool(r[4]), "lastUsed": r[5], "createdAt": r[6],
|
|
1049
|
+
"pipelines": pipes.get(r[0], 0), "runs": runs.get(r[0], 0)}
|
|
1050
|
+
|
|
1051
|
+
def list(self) -> list:
|
|
1052
|
+
"""Every workspace not in the trash, with how many pipelines and runs
|
|
1053
|
+
it holds -- the two counts the Manage table shows (docs/other-tabs.md).
|
|
1054
|
+
Active first, then archived, each by name."""
|
|
1055
|
+
with self.store.lock, self._connect() as db:
|
|
1056
|
+
rows = db.execute(
|
|
1057
|
+
"SELECT id, slug, name, archived, is_default, last_used, created_at "
|
|
1058
|
+
"FROM workspaces WHERE trash IS NULL "
|
|
1059
|
+
"ORDER BY archived, name COLLATE NOCASE").fetchall()
|
|
1060
|
+
runs = {w: n for w, n in db.execute(
|
|
1061
|
+
"SELECT workspace, COUNT(*) FROM queue GROUP BY workspace")}
|
|
1062
|
+
pipes = {}
|
|
1063
|
+
for w, body in db.execute(
|
|
1064
|
+
"SELECT workspace, body FROM docs WHERE name = 'promptlab.workflows'"):
|
|
1065
|
+
try:
|
|
1066
|
+
pipes[w] = len((json.loads(body) or {}).get("list", [])) if body else 0
|
|
1067
|
+
except (ValueError, TypeError):
|
|
1068
|
+
pipes[w] = 0
|
|
1069
|
+
return [self._row(r, pipes, runs) for r in rows]
|
|
1070
|
+
|
|
1071
|
+
def get(self, wid: str) -> dict:
|
|
1072
|
+
"""One workspace by id, with its counts, or None."""
|
|
1073
|
+
return next((w for w in self.list() if w["id"] == wid), None)
|
|
1074
|
+
|
|
1075
|
+
def create(self, name, slug=None, shares=None) -> tuple:
|
|
1076
|
+
"""A new, active workspace; its slug is minted from the name (or a slug
|
|
1077
|
+
asked for), unique across every workspace including the trash, since
|
|
1078
|
+
the column is unique. Returns (id, None) or (None, error). The demo
|
|
1079
|
+
pack is the Handler's to install, since that reaches Packs.
|
|
1080
|
+
|
|
1081
|
+
Shared-by-choice connections (docs/workspaces.md): `shares` is the
|
|
1082
|
+
New-workspace dialog's Connections picker -- a list of {kind, id} the
|
|
1083
|
+
workspace may use, each written as a concrete row. With none given
|
|
1084
|
+
(the plain Add workspace, the CLI), every connection shared with New
|
|
1085
|
+
workspaces (SHARE_NEW) is materialised into a concrete row instead, so
|
|
1086
|
+
"New workspaces" resolves for this one though it was created after the
|
|
1087
|
+
share was set."""
|
|
1088
|
+
name = (name or "").strip()
|
|
1089
|
+
if not name:
|
|
1090
|
+
return None, (400, "a workspace needs a name")
|
|
1091
|
+
if len(name) > WORKSPACE_NAME_MAX:
|
|
1092
|
+
return None, (400, f"a name is at most {WORKSPACE_NAME_MAX} characters")
|
|
1093
|
+
wid = secrets.token_hex(6)
|
|
1094
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
1095
|
+
with self.store.lock, self._connect() as db, db:
|
|
1096
|
+
taken = {r[0] for r in db.execute("SELECT slug FROM workspaces")}
|
|
1097
|
+
s = self._unique(self._slugify(slug or name), taken)
|
|
1098
|
+
db.execute("INSERT INTO workspaces (id, slug, name, archived, is_default, last_used, created_at) "
|
|
1099
|
+
"VALUES (?, ?, ?, 0, 0, ?, ?)", (wid, s, name, now, now))
|
|
1100
|
+
grant = ([(c.get("kind"), c.get("id")) for c in shares if isinstance(c, dict)]
|
|
1101
|
+
if isinstance(shares, list)
|
|
1102
|
+
else list(db.execute("SELECT kind, id FROM connection_shares WHERE workspace = ?",
|
|
1103
|
+
(SHARE_NEW,))))
|
|
1104
|
+
db.executemany("INSERT OR IGNORE INTO connection_shares (kind, id, workspace) VALUES (?, ?, ?)",
|
|
1105
|
+
[(k, i, wid) for k, i in grant if k and i])
|
|
1106
|
+
return wid, None
|
|
1107
|
+
|
|
1108
|
+
def rename(self, wid, name) -> tuple:
|
|
1109
|
+
"""A new name; the slug is kept, so an old bookmark still resolves
|
|
1110
|
+
(docs/workspaces.md). Returns (id, None) or (None, error)."""
|
|
1111
|
+
name = (name or "").strip()
|
|
1112
|
+
if not name:
|
|
1113
|
+
return None, (400, "a workspace needs a name")
|
|
1114
|
+
if len(name) > WORKSPACE_NAME_MAX:
|
|
1115
|
+
return None, (400, f"a name is at most {WORKSPACE_NAME_MAX} characters")
|
|
1116
|
+
with self.store.lock, self._connect() as db, db:
|
|
1117
|
+
if db.execute("SELECT 1 FROM workspaces WHERE id = ? AND trash IS NULL", (wid,)).fetchone() is None:
|
|
1118
|
+
return None, (404, "no such workspace")
|
|
1119
|
+
db.execute("UPDATE workspaces SET name = ? WHERE id = ?", (name, wid))
|
|
1120
|
+
return wid, None
|
|
1121
|
+
|
|
1122
|
+
def regenerate(self, wid) -> tuple:
|
|
1123
|
+
"""A new slug minted from the current name -- the one write that
|
|
1124
|
+
changes an address (docs/workspaces.md). It does not redirect: that is
|
|
1125
|
+
the warning the dialog carries. Returns (id, None) or (None, error)."""
|
|
1126
|
+
with self.store.lock, self._connect() as db, db:
|
|
1127
|
+
row = db.execute("SELECT name FROM workspaces WHERE id = ? AND trash IS NULL", (wid,)).fetchone()
|
|
1128
|
+
if row is None:
|
|
1129
|
+
return None, (404, "no such workspace")
|
|
1130
|
+
taken = {r[0] for r in db.execute("SELECT slug FROM workspaces WHERE id != ?", (wid,))}
|
|
1131
|
+
db.execute("UPDATE workspaces SET slug = ? WHERE id = ?",
|
|
1132
|
+
(self._unique(self._slugify(row[0]), taken), wid))
|
|
1133
|
+
return wid, None
|
|
1134
|
+
|
|
1135
|
+
def archive(self, wid) -> tuple:
|
|
1136
|
+
"""Into the archive, at once; Undo unarchives it. The last active
|
|
1137
|
+
workspace cannot be archived; archiving the flagged default moves the
|
|
1138
|
+
flag to the most recently used other active workspace, keeping the
|
|
1139
|
+
server's cached default in step. Returns (id, None) or (None, error)."""
|
|
1140
|
+
with self.store.lock, self._connect() as db, db:
|
|
1141
|
+
row = db.execute("SELECT archived, is_default FROM workspaces WHERE id = ? AND trash IS NULL",
|
|
1142
|
+
(wid,)).fetchone()
|
|
1143
|
+
if row is None:
|
|
1144
|
+
return None, (404, "no such workspace")
|
|
1145
|
+
if row[0]:
|
|
1146
|
+
return wid, None
|
|
1147
|
+
others = db.execute(
|
|
1148
|
+
"SELECT id FROM workspaces WHERE archived = 0 AND trash IS NULL AND id != ? "
|
|
1149
|
+
"ORDER BY last_used DESC, created_at DESC", (wid,)).fetchall()
|
|
1150
|
+
if not others:
|
|
1151
|
+
return None, (409, "the last active workspace cannot be archived")
|
|
1152
|
+
db.execute("UPDATE workspaces SET archived = 1 WHERE id = ?", (wid,))
|
|
1153
|
+
if row[1]:
|
|
1154
|
+
db.execute("UPDATE workspaces SET is_default = 0 WHERE id = ?", (wid,))
|
|
1155
|
+
db.execute("UPDATE workspaces SET is_default = 1 WHERE id = ?", (others[0][0],))
|
|
1156
|
+
self.store.default_ws = others[0][0]
|
|
1157
|
+
return wid, None
|
|
1158
|
+
|
|
1159
|
+
def set_default(self, wid) -> tuple:
|
|
1160
|
+
"""Make [wid] the flagged default -- the workspace the CLI and an
|
|
1161
|
+
address with no /w/ prefix resolve to (docs/workspaces.md). Only an
|
|
1162
|
+
active workspace can be the default. Returns (id, None) or
|
|
1163
|
+
(None, error)."""
|
|
1164
|
+
with self.store.lock, self._connect() as db, db:
|
|
1165
|
+
row = db.execute("SELECT archived FROM workspaces WHERE id = ? AND trash IS NULL",
|
|
1166
|
+
(wid,)).fetchone()
|
|
1167
|
+
if row is None:
|
|
1168
|
+
return None, (404, "no such workspace")
|
|
1169
|
+
if row[0]:
|
|
1170
|
+
return None, (409, "an archived workspace cannot be the default")
|
|
1171
|
+
db.execute("UPDATE workspaces SET is_default = 0 WHERE is_default = 1")
|
|
1172
|
+
db.execute("UPDATE workspaces SET is_default = 1 WHERE id = ?", (wid,))
|
|
1173
|
+
self.store.default_ws = wid
|
|
1174
|
+
return wid, None
|
|
1175
|
+
|
|
1176
|
+
def unarchive(self, wid) -> tuple:
|
|
1177
|
+
with self.store.lock, self._connect() as db, db:
|
|
1178
|
+
if db.execute("SELECT 1 FROM workspaces WHERE id = ? AND trash IS NULL", (wid,)).fetchone() is None:
|
|
1179
|
+
return None, (404, "no such workspace")
|
|
1180
|
+
db.execute("UPDATE workspaces SET archived = 0 WHERE id = ?", (wid,))
|
|
1181
|
+
return wid, None
|
|
1182
|
+
|
|
1183
|
+
def remove(self, wid) -> tuple:
|
|
1184
|
+
"""Into the trash, at once; Undo restores it. An active workspace is
|
|
1185
|
+
archived first (the Manage menu offers Delete only on an archived one),
|
|
1186
|
+
so a trashed workspace is never the flagged default. Returns
|
|
1187
|
+
(token, None) or (None, error)."""
|
|
1188
|
+
token = secrets.token_hex(6)
|
|
1189
|
+
with self.store.lock, self._connect() as db, db:
|
|
1190
|
+
row = db.execute("SELECT archived FROM workspaces WHERE id = ? AND trash IS NULL",
|
|
1191
|
+
(wid,)).fetchone()
|
|
1192
|
+
if row is None:
|
|
1193
|
+
return None, (404, "no such workspace")
|
|
1194
|
+
if not row[0]:
|
|
1195
|
+
return None, (409, "archive a workspace before deleting it")
|
|
1196
|
+
db.execute("UPDATE workspaces SET trash = ?, trashed_at = ? WHERE id = ?",
|
|
1197
|
+
(token, time.time(), wid))
|
|
1198
|
+
return token, None
|
|
1199
|
+
|
|
1200
|
+
def restore(self, token) -> tuple:
|
|
1201
|
+
"""A trashed workspace back, archived as it was. Returns (id, None) or
|
|
1202
|
+
(None, error)."""
|
|
1203
|
+
with self.store.lock, self._connect() as db, db:
|
|
1204
|
+
row = db.execute("SELECT id FROM workspaces WHERE trash = ?", (str(token),)).fetchone()
|
|
1205
|
+
if row is None:
|
|
1206
|
+
return None, (404, "no such trash entry")
|
|
1207
|
+
db.execute("UPDATE workspaces SET trash = NULL, trashed_at = NULL WHERE id = ?", (row[0],))
|
|
1208
|
+
return row[0], None
|
|
1209
|
+
|
|
1210
|
+
def _purge(self, db, ids) -> tuple:
|
|
1211
|
+
"""Delete each workspace in `ids` and all its scoped data -- the rows
|
|
1212
|
+
across every scopable table and its documents -- returning the Source
|
|
1213
|
+
and run ids whose on-disk directories the caller then removes. The
|
|
1214
|
+
child tables co-scope through their parent id (docs/workspaces.md)."""
|
|
1215
|
+
sids, rids = [], []
|
|
1216
|
+
for ws in ids:
|
|
1217
|
+
sids += [r[0] for r in db.execute(
|
|
1218
|
+
"SELECT id FROM sources WHERE workspace = ? AND system = 0", (ws,))]
|
|
1219
|
+
rids += [r[0] for r in db.execute("SELECT id FROM queue WHERE workspace = ?", (ws,))]
|
|
1220
|
+
db.execute("DELETE FROM source_files WHERE source IN "
|
|
1221
|
+
"(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
|
|
1222
|
+
db.execute("DELETE FROM source_definitions WHERE source IN "
|
|
1223
|
+
"(SELECT id FROM sources WHERE workspace = ? AND system = 0)", (ws,))
|
|
1224
|
+
db.execute("DELETE FROM sources WHERE workspace = ? AND system = 0", (ws,))
|
|
1225
|
+
db.execute("DELETE FROM eval_group_versions WHERE group_id IN "
|
|
1226
|
+
"(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
|
|
1227
|
+
db.execute("DELETE FROM dataset_rules_archive WHERE dataset_id IN "
|
|
1228
|
+
"(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
|
|
1229
|
+
db.execute("DELETE FROM dataset_body_archive WHERE dataset_id IN "
|
|
1230
|
+
"(SELECT id FROM datasets WHERE workspace = ?)", (ws,))
|
|
1231
|
+
db.execute("DELETE FROM datasets WHERE workspace = ?", (ws,))
|
|
1232
|
+
db.execute("DELETE FROM prompt_uses WHERE prompt_id IN "
|
|
1233
|
+
"(SELECT id FROM prompts WHERE workspace = ?)", (ws,))
|
|
1234
|
+
db.execute("DELETE FROM prompt_versions WHERE prompt_id IN "
|
|
1235
|
+
"(SELECT id FROM prompts WHERE workspace = ?)", (ws,))
|
|
1236
|
+
db.execute("DELETE FROM prompts WHERE workspace = ?", (ws,))
|
|
1237
|
+
db.execute("DELETE FROM pack_items WHERE workspace = ?", (ws,))
|
|
1238
|
+
db.execute("DELETE FROM packs WHERE workspace = ?", (ws,))
|
|
1239
|
+
db.execute("DELETE FROM queue WHERE workspace = ?", (ws,))
|
|
1240
|
+
db.execute("DELETE FROM runs WHERE workspace = ?", (ws,))
|
|
1241
|
+
db.execute("DELETE FROM docs WHERE workspace = ?", (ws,))
|
|
1242
|
+
db.execute("DELETE FROM connection_shares WHERE workspace = ?", (ws,))
|
|
1243
|
+
db.execute("DELETE FROM workspaces WHERE id = ?", (ws,))
|
|
1244
|
+
return sids, rids
|
|
1245
|
+
|
|
1246
|
+
def _rmdirs(self, sids, rids):
|
|
1247
|
+
for sid in sids:
|
|
1248
|
+
shutil.rmtree(self.dir / "sources" / sid, ignore_errors=True)
|
|
1249
|
+
for rid in rids:
|
|
1250
|
+
shutil.rmtree(self.dir / "runs" / rid, ignore_errors=True)
|
|
1251
|
+
|
|
1252
|
+
def lazy_trash(self):
|
|
1253
|
+
"""A /api/workspaces request purges what has been trashed longer than
|
|
1254
|
+
TRASH_SECONDS, lazily as the other trashes do."""
|
|
1255
|
+
with self.store.lock, self._connect() as db, db:
|
|
1256
|
+
gone = [r[0] for r in db.execute(
|
|
1257
|
+
"SELECT id FROM workspaces WHERE trash IS NOT NULL AND trashed_at < ?",
|
|
1258
|
+
(time.time() - TRASH_SECONDS,))]
|
|
1259
|
+
sids, rids = self._purge(db, gone)
|
|
1260
|
+
self._rmdirs(sids, rids)
|
|
1261
|
+
|
|
1262
|
+
def empty_trash(self):
|
|
1263
|
+
"""The startup sweep: a restart has nothing to undo."""
|
|
1264
|
+
with self.store.lock, self._connect() as db, db:
|
|
1265
|
+
gone = [r[0] for r in db.execute("SELECT id FROM workspaces WHERE trash IS NOT NULL")]
|
|
1266
|
+
sids, rids = self._purge(db, gone)
|
|
1267
|
+
self._rmdirs(sids, rids)
|
|
1268
|
+
|
|
711
1269
|
|
|
712
1270
|
# A filename, the target filesystem's view rather than the caller's: a
|
|
713
1271
|
# basename is all that survives, restricted to characters that filesystem
|
|
@@ -781,6 +1339,34 @@ class Sources:
|
|
|
781
1339
|
f"DEFAULT '{DEFAULT_SOURCE_TYPE}'")
|
|
782
1340
|
if "config" not in have:
|
|
783
1341
|
db.execute("ALTER TABLE sources ADD COLUMN config TEXT")
|
|
1342
|
+
# The workspace a Source belongs to (docs/workspaces.md). A store
|
|
1343
|
+
# from before workspaces gains the column; every user Source with
|
|
1344
|
+
# none -- a pre-workspace row, or one left unscoped by any edge --
|
|
1345
|
+
# backfills to the default on open, idempotently, while the system
|
|
1346
|
+
# samples row stays workspace-agnostic (NULL) so it shows in every
|
|
1347
|
+
# workspace.
|
|
1348
|
+
if "workspace" not in have:
|
|
1349
|
+
db.execute("ALTER TABLE sources ADD COLUMN workspace TEXT")
|
|
1350
|
+
db.execute("UPDATE sources SET workspace = ? WHERE workspace IS NULL AND system = 0",
|
|
1351
|
+
(store.default_ws,))
|
|
1352
|
+
# A Power Automate Source was its own kind once (type
|
|
1353
|
+
# "power-automate", docs/power-automate.md); it is now the generic
|
|
1354
|
+
# workflow kind, the platform named in its config
|
|
1355
|
+
# (docs/sources-tab.md). Converted here, once and idempotently: a
|
|
1356
|
+
# later open finds none left. remove()/restore_trash keep a row's
|
|
1357
|
+
# type and config, so Undo brings a converted Source back exactly,
|
|
1358
|
+
# still speaking to Power Automate.
|
|
1359
|
+
for sid, config in db.execute(
|
|
1360
|
+
"SELECT id, config FROM sources WHERE type = 'power-automate'").fetchall():
|
|
1361
|
+
try:
|
|
1362
|
+
cfg = json.loads(config) if config else {}
|
|
1363
|
+
except ValueError:
|
|
1364
|
+
cfg = {}
|
|
1365
|
+
if not isinstance(cfg, dict):
|
|
1366
|
+
cfg = {}
|
|
1367
|
+
cfg["platform"] = "power-automate"
|
|
1368
|
+
db.execute("UPDATE sources SET type = 'workflow', config = ? WHERE id = ?",
|
|
1369
|
+
(json.dumps(cfg), sid))
|
|
784
1370
|
# A flow's definition, one row per version: a snapshot the page
|
|
785
1371
|
# took and redacted, redacted again here.
|
|
786
1372
|
db.execute("CREATE TABLE IF NOT EXISTS source_definitions ("
|
|
@@ -809,8 +1395,9 @@ class Sources:
|
|
|
809
1395
|
out = []
|
|
810
1396
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
811
1397
|
db.row_factory = sqlite3.Row
|
|
812
|
-
for r in db.execute("SELECT id, name, system, bytes, type, created_at FROM sources "
|
|
813
|
-
"
|
|
1398
|
+
for r in db.execute("SELECT id, name, system, bytes, type, config, created_at FROM sources "
|
|
1399
|
+
"WHERE workspace = ? OR system = 1 "
|
|
1400
|
+
"ORDER BY system DESC, name COLLATE NOCASE", (workspace_of(self.store),)):
|
|
814
1401
|
if r["system"]:
|
|
815
1402
|
files = self._sample_files()
|
|
816
1403
|
out.append({"id": r["id"], "name": r["name"], "system": True,
|
|
@@ -821,17 +1408,24 @@ class Sources:
|
|
|
821
1408
|
(r["id"],)).fetchone()
|
|
822
1409
|
# When it last changed: made, or a file added -- what a
|
|
823
1410
|
# picker orders its recent Sources by.
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1411
|
+
row = {"id": r["id"], "name": r["name"], "system": False,
|
|
1412
|
+
"type": r["type"], "files": n, "bytes": r["bytes"],
|
|
1413
|
+
"changed": max(filter(None, (r["created_at"], last)))}
|
|
1414
|
+
# A workflow's platform is how the page labels it; nothing
|
|
1415
|
+
# else of its config rides in the summary.
|
|
1416
|
+
platform = _config_platform(r["config"])
|
|
1417
|
+
if platform is not None:
|
|
1418
|
+
row["platform"] = platform
|
|
1419
|
+
out.append(row)
|
|
827
1420
|
return out
|
|
828
1421
|
|
|
829
1422
|
def get(self, sid) -> dict:
|
|
830
1423
|
"""One Source with its file list, or None."""
|
|
831
1424
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
832
1425
|
db.row_factory = sqlite3.Row
|
|
833
|
-
r = db.execute("SELECT id, name, system, bytes, type, config FROM sources
|
|
834
|
-
(
|
|
1426
|
+
r = db.execute("SELECT id, name, system, bytes, type, config FROM sources "
|
|
1427
|
+
"WHERE id = ? AND (workspace = ? OR system = 1)",
|
|
1428
|
+
(sid, workspace_of(self.store))).fetchone()
|
|
835
1429
|
if r is None:
|
|
836
1430
|
return None
|
|
837
1431
|
if r["system"]:
|
|
@@ -878,14 +1472,18 @@ class Sources:
|
|
|
878
1472
|
return None, (400, "that name is too long")
|
|
879
1473
|
if not isinstance(kind, str) or kind not in SOURCE_TYPES:
|
|
880
1474
|
return None, (400, f"the lab has no Source type {kind!r}")
|
|
1475
|
+
# A workflow kind needs a platform the lab knows, named in its config.
|
|
1476
|
+
if source_entry(kind, config) is None:
|
|
1477
|
+
return None, (400, "the lab has no such Source platform")
|
|
881
1478
|
kept, err = self._config(config)
|
|
882
1479
|
if err:
|
|
883
1480
|
return None, err
|
|
884
1481
|
sid = secrets.token_hex(6)
|
|
885
1482
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
886
|
-
db.execute("INSERT INTO sources (id, name, system, bytes, created_at, type, config) "
|
|
887
|
-
"VALUES (?, ?, 0, 0, ?, ?, ?)",
|
|
888
|
-
(sid, name, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), kind, kept
|
|
1483
|
+
db.execute("INSERT INTO sources (id, name, system, bytes, created_at, type, config, workspace) "
|
|
1484
|
+
"VALUES (?, ?, 0, 0, ?, ?, ?, ?)",
|
|
1485
|
+
(sid, name, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), kind, kept,
|
|
1486
|
+
workspace_of(self.store)))
|
|
889
1487
|
(self.dir / sid).mkdir(parents=True, exist_ok=True)
|
|
890
1488
|
return {"id": sid, "name": name, "system": False, "type": kind,
|
|
891
1489
|
"config": json.loads(kept) if kept else None, "files": [], "bytes": 0}, None
|
|
@@ -896,8 +1494,8 @@ class Sources:
|
|
|
896
1494
|
"""A Source's newest definition and the versions kept, or None for a
|
|
897
1495
|
Source that is not there or keeps none."""
|
|
898
1496
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
899
|
-
r = db.execute("SELECT type FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
900
|
-
if r is None or not (
|
|
1497
|
+
r = db.execute("SELECT type, config FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
1498
|
+
if r is None or not (source_entry(r[0], json.loads(r[1]) if r[1] else None) or {}).get("definition"):
|
|
901
1499
|
return None
|
|
902
1500
|
rows = db.execute("SELECT version, body, at FROM source_definitions WHERE source = ? "
|
|
903
1501
|
"ORDER BY version DESC", (sid,)).fetchall()
|
|
@@ -923,10 +1521,10 @@ class Sources:
|
|
|
923
1521
|
return None, (413, "that definition is over the cap")
|
|
924
1522
|
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
925
1523
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
926
|
-
r = db.execute("SELECT type FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
1524
|
+
r = db.execute("SELECT type, config FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
927
1525
|
if r is None:
|
|
928
1526
|
return None, (404, "no such source")
|
|
929
|
-
if not (
|
|
1527
|
+
if not (source_entry(r[0], json.loads(r[1]) if r[1] else None) or {}).get("definition"):
|
|
930
1528
|
return None, (400, "a Source of that type keeps no definition")
|
|
931
1529
|
current = db.execute("SELECT MAX(version) FROM source_definitions WHERE source = ?",
|
|
932
1530
|
(sid,)).fetchone()[0] or 0
|
|
@@ -956,10 +1554,15 @@ class Sources:
|
|
|
956
1554
|
db.execute("DELETE FROM source_definitions WHERE source = ?", (sid,))
|
|
957
1555
|
|
|
958
1556
|
def type_of(self, sid):
|
|
959
|
-
"""A Source's
|
|
1557
|
+
"""A Source's enforcement entry -- its kind's, or its workflow
|
|
1558
|
+
platform's (source_entry); None for no such Source. {} for a known
|
|
1559
|
+
Source whose platform the lab does not know, so a reader asks the
|
|
1560
|
+
entry rather than the id."""
|
|
960
1561
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
961
|
-
r = db.execute("SELECT type FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
962
|
-
|
|
1562
|
+
r = db.execute("SELECT type, config FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
1563
|
+
if r is None:
|
|
1564
|
+
return None
|
|
1565
|
+
return source_entry(r[0], json.loads(r[1]) if r[1] else None) or {}
|
|
963
1566
|
|
|
964
1567
|
def uploads(self, sid):
|
|
965
1568
|
"""The files a Source takes, by extension, as its type declares them;
|
|
@@ -975,7 +1578,7 @@ class Sources:
|
|
|
975
1578
|
if len(name) > 80:
|
|
976
1579
|
return None, (400, "that name is too long")
|
|
977
1580
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
978
|
-
r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
1581
|
+
r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
979
1582
|
if r is None:
|
|
980
1583
|
return None, (404, "no such source")
|
|
981
1584
|
if r[0]:
|
|
@@ -988,8 +1591,9 @@ class Sources:
|
|
|
988
1591
|
Undo can restore it; the system Source is undeletable. Returns
|
|
989
1592
|
(token, None), or (None, error)."""
|
|
990
1593
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
991
|
-
r = db.execute("SELECT system, name, created_at, type, config FROM sources "
|
|
992
|
-
"WHERE id = ?
|
|
1594
|
+
r = db.execute("SELECT system, name, created_at, type, config, workspace FROM sources "
|
|
1595
|
+
"WHERE id = ? AND (workspace = ? OR system = 1)",
|
|
1596
|
+
(sid, workspace_of(self.store))).fetchone()
|
|
993
1597
|
if r is None:
|
|
994
1598
|
return None, (404, "no such source")
|
|
995
1599
|
if r[0]:
|
|
@@ -1004,7 +1608,7 @@ class Sources:
|
|
|
1004
1608
|
entry.mkdir(parents=True, exist_ok=True)
|
|
1005
1609
|
(entry / "manifest.json").write_text(json.dumps({
|
|
1006
1610
|
"kind": "source", "source": sid, "name": r[1], "created": r[2],
|
|
1007
|
-
"type": r[3], "config": r[4], "at": time.time(), "files": files}))
|
|
1611
|
+
"type": r[3], "config": r[4], "workspace": r[5], "at": time.time(), "files": files}))
|
|
1008
1612
|
if (self.dir / sid).is_dir():
|
|
1009
1613
|
shutil.move(str(self.dir / sid), str(entry / sid))
|
|
1010
1614
|
return token, None
|
|
@@ -1017,7 +1621,7 @@ class Sources:
|
|
|
1017
1621
|
return None, (400, "no files named")
|
|
1018
1622
|
names = list(dict.fromkeys(names))
|
|
1019
1623
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
1020
|
-
r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
1624
|
+
r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
1021
1625
|
if r is None:
|
|
1022
1626
|
return None, (404, "no such source")
|
|
1023
1627
|
if r[0]:
|
|
@@ -1072,19 +1676,22 @@ class Sources:
|
|
|
1072
1676
|
sid, name = m.get("source"), m.get("name")
|
|
1073
1677
|
if not isinstance(sid, str) or not isinstance(name, str):
|
|
1074
1678
|
return None, (404, "no such trash entry")
|
|
1679
|
+
# Back into the workspace it was trashed from (an older trash entry
|
|
1680
|
+
# has none: the default). A name is unique within a workspace.
|
|
1681
|
+
ws = m.get("workspace") or self.store.default_ws
|
|
1075
1682
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
1076
1683
|
if db.execute("SELECT 1 FROM sources WHERE name = ? COLLATE NOCASE "
|
|
1077
|
-
"AND system = 0", (name,)).fetchone():
|
|
1684
|
+
"AND system = 0 AND workspace = ?", (name, ws)).fetchone():
|
|
1078
1685
|
return None, (409, f"{name!r} has been taken since, so nothing was restored")
|
|
1079
1686
|
with db:
|
|
1080
1687
|
kind = m.get("type")
|
|
1081
1688
|
db.execute("INSERT INTO sources (id, name, system, bytes, created_at, "
|
|
1082
|
-
"type, config) VALUES (?, ?, 0, ?, ?, ?, ?)",
|
|
1689
|
+
"type, config, workspace) VALUES (?, ?, 0, ?, ?, ?, ?, ?)",
|
|
1083
1690
|
(sid, name, sum(f.get("bytes", 0) for f in m["files"]),
|
|
1084
1691
|
m.get("created", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
|
|
1085
1692
|
# As it was: a restore puts the row back, not a guess at it.
|
|
1086
1693
|
kind if isinstance(kind, str) else DEFAULT_SOURCE_TYPE,
|
|
1087
|
-
m.get("config") if isinstance(m.get("config"), str) else None))
|
|
1694
|
+
m.get("config") if isinstance(m.get("config"), str) else None, ws))
|
|
1088
1695
|
for f in m["files"]:
|
|
1089
1696
|
if isinstance(f, dict) and isinstance(f.get("name"), str):
|
|
1090
1697
|
db.execute("INSERT INTO source_files (source, name, bytes, at) "
|
|
@@ -1100,7 +1707,8 @@ class Sources:
|
|
|
1100
1707
|
return None, (404, "no such trash entry")
|
|
1101
1708
|
total = sum(f.get("bytes", 0) for f in files)
|
|
1102
1709
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
1103
|
-
r = db.execute("SELECT system, bytes FROM sources WHERE id = ?
|
|
1710
|
+
r = db.execute("SELECT system, bytes FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
|
|
1711
|
+
(sid, workspace_of(self.store))).fetchone()
|
|
1104
1712
|
if r is None:
|
|
1105
1713
|
return None, (409, "the Source that held these files is gone, so nothing was restored")
|
|
1106
1714
|
if r[0]:
|
|
@@ -1171,14 +1779,15 @@ class Sources:
|
|
|
1171
1779
|
names = list(dict.fromkeys(names))
|
|
1172
1780
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
1173
1781
|
db.row_factory = sqlite3.Row
|
|
1174
|
-
|
|
1175
|
-
|
|
1782
|
+
ws = workspace_of(self.store)
|
|
1783
|
+
dest = db.execute("SELECT system, bytes, type FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
|
|
1784
|
+
(sid, ws)).fetchone()
|
|
1176
1785
|
if dest is None:
|
|
1177
1786
|
return None, (404, "no such source")
|
|
1178
1787
|
if dest["system"]:
|
|
1179
1788
|
return None, (403, "the sample library cannot be written to")
|
|
1180
|
-
src = db.execute("SELECT system, type FROM sources WHERE id = ?",
|
|
1181
|
-
(from_sid,)).fetchone()
|
|
1789
|
+
src = db.execute("SELECT system, type FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
|
|
1790
|
+
(from_sid, ws)).fetchone()
|
|
1182
1791
|
if src is None:
|
|
1183
1792
|
return None, (404, "no such source")
|
|
1184
1793
|
# A file means what its Source's type says it means, so it only
|
|
@@ -1267,7 +1876,7 @@ class Sources:
|
|
|
1267
1876
|
"""Every file of a Source, as (name, path on disk) in its own order,
|
|
1268
1877
|
or None when there is no such Source."""
|
|
1269
1878
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
1270
|
-
r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
1879
|
+
r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
1271
1880
|
if r is None:
|
|
1272
1881
|
return None
|
|
1273
1882
|
if r[0]:
|
|
@@ -1284,7 +1893,7 @@ class Sources:
|
|
|
1284
1893
|
or not all(isinstance(n, str) for n in names):
|
|
1285
1894
|
return None, (400, "the names are needed")
|
|
1286
1895
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
1287
|
-
r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
1896
|
+
r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
1288
1897
|
if r is None:
|
|
1289
1898
|
return None, (404, "no such source")
|
|
1290
1899
|
if r[0]:
|
|
@@ -1350,7 +1959,8 @@ class Sources:
|
|
|
1350
1959
|
"""
|
|
1351
1960
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
1352
1961
|
db.row_factory = sqlite3.Row
|
|
1353
|
-
r = db.execute("SELECT system, bytes FROM sources WHERE id = ?
|
|
1962
|
+
r = db.execute("SELECT system, bytes FROM sources WHERE id = ? AND (workspace = ? OR system = 1)",
|
|
1963
|
+
(sid, workspace_of(self.store))).fetchone()
|
|
1354
1964
|
if r is None:
|
|
1355
1965
|
return None, (404, "no such source")
|
|
1356
1966
|
if r["system"]:
|
|
@@ -1382,7 +1992,7 @@ class Sources:
|
|
|
1382
1992
|
def file_path(self, sid: str, name: str) -> Path:
|
|
1383
1993
|
"""The on-disk path of a stored file, or None. `name` is already clean."""
|
|
1384
1994
|
with self.store.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
1385
|
-
r = db.execute("SELECT system FROM sources WHERE id = ?", (sid,)).fetchone()
|
|
1995
|
+
r = db.execute("SELECT system FROM sources WHERE id = ? AND (workspace = ? OR system = 1)", (sid, workspace_of(self.store))).fetchone()
|
|
1386
1996
|
if r is None:
|
|
1387
1997
|
return None
|
|
1388
1998
|
if r[0]:
|
|
@@ -1455,22 +2065,23 @@ class Sources:
|
|
|
1455
2065
|
# it is left where it is, and nothing reads it.
|
|
1456
2066
|
|
|
1457
2067
|
DATASET_FIELDS = ("version", "source", "scoring", "grader", "every", "run", "cases")
|
|
1458
|
-
# A body's own version: evals-core.ts's DATASET_BODY_VERSION. Version
|
|
2068
|
+
# A body's own version: evals-core.ts's DATASET_BODY_VERSION. Version 8 reads
|
|
2069
|
+
# the recorded-reply metric ids under their new names (#299); version 7 is an
|
|
1459
2070
|
# eval group (docs/pipeline-model.md §17), version 6 its cases alone; version
|
|
1460
2071
|
# 5 was told by its `source` alone, and earlier ones by neither.
|
|
1461
|
-
DATASET_BODY_VERSION =
|
|
2072
|
+
DATASET_BODY_VERSION = 8
|
|
1462
2073
|
DATASET_NAME_MAX = 80
|
|
1463
2074
|
# The file forms Export writes and Import reads. Export writes an eval group
|
|
1464
|
-
# at version
|
|
1465
|
-
# file of versions 1 to
|
|
2075
|
+
# at version 8 (docs/pipeline-model.md §17); Import reads that, and a dataset
|
|
2076
|
+
# file of versions 1 to 8, upgraded, and refuses anything else, as a pipeline
|
|
1466
2077
|
# of another version is refused. Versions 1 to 3 carried a prompt, which an
|
|
1467
2078
|
# import gives to the Prompt library.
|
|
1468
2079
|
EXPORT_ONE = "evals-lab/eval-group"
|
|
1469
2080
|
EXPORT_ALL = "evals-lab/eval-groups"
|
|
1470
2081
|
DATASET_ONE = "evals-lab/dataset"
|
|
1471
2082
|
DATASET_ALL = "evals-lab/datasets"
|
|
1472
|
-
EXPORT_VERSION =
|
|
1473
|
-
IMPORT_VERSIONS = (1, 2, 3, 4, 5, 6, 7)
|
|
2083
|
+
EXPORT_VERSION = 8
|
|
2084
|
+
IMPORT_VERSIONS = (1, 2, 3, 4, 5, 6, 7, 8)
|
|
1474
2085
|
# Each file form, and the key its one entry or its list sits under.
|
|
1475
2086
|
EXPORT_KEYS = {EXPORT_ONE: "group", EXPORT_ALL: "groups", DATASET_ONE: "dataset", DATASET_ALL: "datasets"}
|
|
1476
2087
|
SCORING_MODES = ("all", "weighted")
|
|
@@ -1508,7 +2119,7 @@ def _term_in(items, term) -> bool:
|
|
|
1508
2119
|
def case_metrics(c: dict) -> list:
|
|
1509
2120
|
"""A version-4 case's expectations as the metrics that say the same:
|
|
1510
2121
|
evals-core.ts's caseMetrics, in Python, and held to it by proxy-check.py
|
|
1511
|
-
through fixtures/dataset-
|
|
2122
|
+
through fixtures/dataset-v8.json."""
|
|
1512
2123
|
def strs(v):
|
|
1513
2124
|
return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
|
|
1514
2125
|
if c.get("discarded") is True:
|
|
@@ -1575,7 +2186,7 @@ def case_of_v5(c):
|
|
|
1575
2186
|
|
|
1576
2187
|
|
|
1577
2188
|
def group_of_v6(body: dict) -> dict:
|
|
1578
|
-
"""A version-6 body as a version-
|
|
2189
|
+
"""A version-6 body as a version-8 eval group: evals-core.ts's
|
|
1579
2190
|
groupOfV6. Scored All, the lab's grader, and no metrics of its own for
|
|
1580
2191
|
every item or the whole run -- what a Metrics eval naming the dataset with
|
|
1581
2192
|
none of its own graded."""
|
|
@@ -1584,8 +2195,43 @@ def group_of_v6(body: dict) -> dict:
|
|
|
1584
2195
|
"grader": None, "every": [], "run": [], **rest}
|
|
1585
2196
|
|
|
1586
2197
|
|
|
2198
|
+
# The recorded-reply metric ids renamed at version 8: evals-core.ts's
|
|
2199
|
+
# RECORDED_IDS. Stored tokens inside eval group bodies and a pipeline's private
|
|
2200
|
+
# group, so they are mapped wherever a body is read (#299).
|
|
2201
|
+
RECORDED_IDS = {
|
|
2202
|
+
"equals-production": "same-as-recorded",
|
|
2203
|
+
"fields-equal-production": "fields-equal-recorded",
|
|
2204
|
+
"same-parse-outcome": "same-parse-as-recorded",
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
def _rename_metric(m):
|
|
2209
|
+
return {**m, "type": RECORDED_IDS[m["type"]]} if isinstance(m, dict) and m.get("type") in RECORDED_IDS else m
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def _rename_metrics(lst):
|
|
2213
|
+
return [_rename_metric(m) for m in lst] if isinstance(lst, list) else lst
|
|
2214
|
+
|
|
2215
|
+
|
|
2216
|
+
def recorded_ids_v7(body):
|
|
2217
|
+
"""A version-7 body (or a freshly made version-8 one) with its
|
|
2218
|
+
recorded-reply metric ids read under their version-8 names, at version 8:
|
|
2219
|
+
evals-core.ts's recordedIdsV7."""
|
|
2220
|
+
out = dict(body)
|
|
2221
|
+
out["version"] = DATASET_BODY_VERSION
|
|
2222
|
+
if isinstance(body.get("every"), list):
|
|
2223
|
+
out["every"] = _rename_metrics(body["every"])
|
|
2224
|
+
if isinstance(body.get("run"), list):
|
|
2225
|
+
out["run"] = _rename_metrics(body["run"])
|
|
2226
|
+
if isinstance(body.get("cases"), list):
|
|
2227
|
+
out["cases"] = [{**c, "metrics": _rename_metrics(c["metrics"])}
|
|
2228
|
+
if isinstance(c, dict) and isinstance(c.get("metrics"), list) else c
|
|
2229
|
+
for c in body["cases"]]
|
|
2230
|
+
return out
|
|
2231
|
+
|
|
2232
|
+
|
|
1587
2233
|
def upgrade_body(body):
|
|
1588
|
-
"""An earlier body as today's (version
|
|
2234
|
+
"""An earlier body as today's (version 8): evals-core.ts's
|
|
1589
2235
|
upgradeDatasetBody, in Python. Version 1's `imageCases` are `cases`, and
|
|
1590
2236
|
its `replays` and `conformance` go (fixtures/replays.json holds the
|
|
1591
2237
|
parser's tests). Version 2's `rules` go -- they clean a job's answer, so
|
|
@@ -1596,24 +2242,28 @@ def upgrade_body(body):
|
|
|
1596
2242
|
needs the rules or the prompt takes them first (`body_rules`,
|
|
1597
2243
|
`body_prompt`). A body naming its Source is version 5, whose Contains
|
|
1598
2244
|
metrics each come to say Ignore case (`case_of_v5`). Version 6 gains a
|
|
1599
|
-
group's scoring, grader, Every item and Whole run (`group_of_v6`)
|
|
1600
|
-
|
|
1601
|
-
|
|
2245
|
+
group's scoring, grader, Every item and Whole run (`group_of_v6`); version
|
|
2246
|
+
7 reads its recorded-reply metric ids under their version-8 names
|
|
2247
|
+
(`recorded_ids_v7`). A body saying it is version 8 comes back as it was;
|
|
2248
|
+
so does anything that is not a body."""
|
|
1602
2249
|
if not isinstance(body, dict) or body.get("version") == DATASET_BODY_VERSION:
|
|
1603
2250
|
return body
|
|
1604
|
-
# A body saying any other version is one this lab does not read, and is
|
|
1605
|
-
# left for dataset_problem to refuse.
|
|
1606
2251
|
if "version" in body:
|
|
1607
|
-
|
|
2252
|
+
# Version 7 reads its recorded-reply metric ids under their version-8
|
|
2253
|
+
# names; version 6 is its cases alone. Any other version is one this
|
|
2254
|
+
# lab does not read, left for dataset_problem to refuse.
|
|
2255
|
+
if body["version"] == 7:
|
|
2256
|
+
return recorded_ids_v7(body)
|
|
2257
|
+
return recorded_ids_v7(group_of_v6(body)) if body["version"] == 6 else body
|
|
1608
2258
|
if "source" in body:
|
|
1609
2259
|
up = dict(body)
|
|
1610
2260
|
if isinstance(body.get("cases"), list):
|
|
1611
2261
|
up["cases"] = [case_of_v5(c) for c in body["cases"]]
|
|
1612
|
-
return group_of_v6(up)
|
|
2262
|
+
return recorded_ids_v7(group_of_v6(up))
|
|
1613
2263
|
cases = body.get("cases") if isinstance(body.get("cases"), list) else body.get("imageCases")
|
|
1614
2264
|
if not isinstance(cases, list):
|
|
1615
2265
|
return body
|
|
1616
|
-
return group_of_v6({"source": None, "cases": [case_of_v5(case_of_v4(c)) for c in cases]})
|
|
2266
|
+
return recorded_ids_v7(group_of_v6({"source": None, "cases": [case_of_v5(case_of_v4(c)) for c in cases]}))
|
|
1617
2267
|
|
|
1618
2268
|
|
|
1619
2269
|
def body_prompt(body):
|
|
@@ -1785,6 +2435,13 @@ class Prompts:
|
|
|
1785
2435
|
cols = {r[1] for r in db.execute("PRAGMA table_info(prompt_uses)")}
|
|
1786
2436
|
if "chain" in cols and "job" not in cols:
|
|
1787
2437
|
db.execute("ALTER TABLE prompt_uses RENAME COLUMN chain TO job")
|
|
2438
|
+
# The workspace a prompt belongs to (docs/workspaces.md): the
|
|
2439
|
+
# Default is per-workspace, so is_default is scoped too. Children
|
|
2440
|
+
# (versions, uses) co-scope through the prompt id. A store from
|
|
2441
|
+
# before workspaces backfills every prompt to the default.
|
|
2442
|
+
if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(prompts)")}:
|
|
2443
|
+
db.execute("ALTER TABLE prompts ADD COLUMN workspace TEXT")
|
|
2444
|
+
db.execute("UPDATE prompts SET workspace = ? WHERE workspace IS NULL", (store.default_ws,))
|
|
1788
2445
|
# One-off facts about the library: that the runs from before it
|
|
1789
2446
|
# have been read into it, so a restart does not read them again
|
|
1790
2447
|
# and bring back a prompt someone has since deleted.
|
|
@@ -1799,9 +2456,9 @@ class Prompts:
|
|
|
1799
2456
|
db.row_factory = sqlite3.Row
|
|
1800
2457
|
return closing(db)
|
|
1801
2458
|
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
2459
|
+
def _live(self, db, pid):
|
|
2460
|
+
return db.execute("SELECT * FROM prompts WHERE id = ? AND trash IS NULL AND workspace = ?",
|
|
2461
|
+
(pid, workspace_of(self.store))).fetchone()
|
|
1805
2462
|
|
|
1806
2463
|
@staticmethod
|
|
1807
2464
|
def _head(db, pid):
|
|
@@ -1840,7 +2497,8 @@ class Prompts:
|
|
|
1840
2497
|
def list(self) -> list:
|
|
1841
2498
|
with self.store.lock, self._connect() as db:
|
|
1842
2499
|
return [self._summary(db, r) for r in db.execute(
|
|
1843
|
-
"SELECT * FROM prompts WHERE trash IS NULL ORDER BY updated_at DESC, id"
|
|
2500
|
+
"SELECT * FROM prompts WHERE trash IS NULL AND workspace = ? ORDER BY updated_at DESC, id",
|
|
2501
|
+
(workspace_of(self.store),))]
|
|
1844
2502
|
|
|
1845
2503
|
def get(self, pid):
|
|
1846
2504
|
with self.store.lock, self._connect() as db:
|
|
@@ -1850,7 +2508,8 @@ class Prompts:
|
|
|
1850
2508
|
def default(self):
|
|
1851
2509
|
"""The Default's newest version, {id, name, version, text}, or None."""
|
|
1852
2510
|
with self.store.lock, self._connect() as db:
|
|
1853
|
-
r = db.execute("SELECT * FROM prompts WHERE is_default = 1 AND trash IS NULL"
|
|
2511
|
+
r = db.execute("SELECT * FROM prompts WHERE is_default = 1 AND trash IS NULL AND workspace = ?",
|
|
2512
|
+
(workspace_of(self.store),)).fetchone()
|
|
1854
2513
|
if r is None:
|
|
1855
2514
|
return None
|
|
1856
2515
|
head = self._head(db, r["id"])
|
|
@@ -1862,10 +2521,11 @@ class Prompts:
|
|
|
1862
2521
|
def _insert(self, db, name, text, default=False):
|
|
1863
2522
|
pid = secrets.token_hex(6)
|
|
1864
2523
|
now = self._now()
|
|
2524
|
+
ws = workspace_of(self.store)
|
|
1865
2525
|
if default:
|
|
1866
|
-
db.execute("UPDATE prompts SET is_default = 0")
|
|
1867
|
-
db.execute("INSERT INTO prompts (id, name, is_default, created_at, updated_at)
|
|
1868
|
-
(pid, name, 1 if default else 0, now, now))
|
|
2526
|
+
db.execute("UPDATE prompts SET is_default = 0 WHERE workspace = ?", (ws,))
|
|
2527
|
+
db.execute("INSERT INTO prompts (id, name, is_default, created_at, updated_at, workspace) "
|
|
2528
|
+
"VALUES (?, ?, ?, ?, ?, ?)", (pid, name, 1 if default else 0, now, now, ws))
|
|
1869
2529
|
db.execute("INSERT INTO prompt_versions (prompt_id, version, text, created_at, edited_at) "
|
|
1870
2530
|
"VALUES (?, 1, ?, ?, ?)", (pid, text, now, time.time()))
|
|
1871
2531
|
return pid
|
|
@@ -1880,7 +2540,8 @@ class Prompts:
|
|
|
1880
2540
|
return version
|
|
1881
2541
|
|
|
1882
2542
|
def _has_default(self, db):
|
|
1883
|
-
return db.execute("SELECT 1 FROM prompts WHERE is_default = 1 AND trash IS NULL
|
|
2543
|
+
return db.execute("SELECT 1 FROM prompts WHERE is_default = 1 AND trash IS NULL AND workspace = ?",
|
|
2544
|
+
(workspace_of(self.store),)).fetchone() is not None
|
|
1884
2545
|
|
|
1885
2546
|
def adopt(self, db, text, name="", default=False):
|
|
1886
2547
|
"""A prompt reading [text]: the live one that already does, or a new
|
|
@@ -1896,13 +2557,13 @@ class Prompts:
|
|
|
1896
2557
|
db.execute("UPDATE prompts SET is_default = 1 WHERE id = ?", (pid,))
|
|
1897
2558
|
return pid
|
|
1898
2559
|
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
2560
|
+
def _matching(self, db, text):
|
|
2561
|
+
"""The newest version of any live prompt in this workspace reading
|
|
2562
|
+
exactly [text]."""
|
|
1902
2563
|
return db.execute("SELECT v.prompt_id, v.version FROM prompt_versions v "
|
|
1903
|
-
"JOIN prompts p ON p.id = v.prompt_id AND p.trash IS NULL "
|
|
2564
|
+
"JOIN prompts p ON p.id = v.prompt_id AND p.trash IS NULL AND p.workspace = ? "
|
|
1904
2565
|
"WHERE v.text = ? ORDER BY v.created_at DESC, v.version DESC LIMIT 1",
|
|
1905
|
-
(text
|
|
2566
|
+
(workspace_of(self.store), text)).fetchone()
|
|
1906
2567
|
|
|
1907
2568
|
def record(self, db, rid, run, at):
|
|
1908
2569
|
"""A run's uses, one per scenario per job, linked by the rules in
|
|
@@ -2000,7 +2661,7 @@ class Prompts:
|
|
|
2000
2661
|
with self.store.lock, self._connect() as db, db:
|
|
2001
2662
|
if self._live(db, pid) is None:
|
|
2002
2663
|
return None, (404, "no such prompt")
|
|
2003
|
-
db.execute("UPDATE prompts SET is_default = 0")
|
|
2664
|
+
db.execute("UPDATE prompts SET is_default = 0 WHERE workspace = ?", (workspace_of(self.store),))
|
|
2004
2665
|
db.execute("UPDATE prompts SET is_default = 1 WHERE id = ?", (pid,))
|
|
2005
2666
|
return self._detail(db, self._live(db, pid)), None
|
|
2006
2667
|
|
|
@@ -2032,7 +2693,8 @@ class Prompts:
|
|
|
2032
2693
|
|
|
2033
2694
|
def restore(self, token):
|
|
2034
2695
|
with self.store.lock, self._connect() as db, db:
|
|
2035
|
-
r = db.execute("SELECT * FROM prompts WHERE trash = ?",
|
|
2696
|
+
r = db.execute("SELECT * FROM prompts WHERE trash = ? AND workspace = ?",
|
|
2697
|
+
(str(token), workspace_of(self.store))).fetchone()
|
|
2036
2698
|
if r is None:
|
|
2037
2699
|
return None, (404, "no such trash entry")
|
|
2038
2700
|
db.execute("UPDATE prompts SET trash = NULL, trashed_at = NULL WHERE id = ?", (r["id"],))
|
|
@@ -2084,6 +2746,12 @@ class Datasets:
|
|
|
2084
2746
|
"group_id TEXT NOT NULL, n INTEGER NOT NULL, body TEXT NOT NULL, "
|
|
2085
2747
|
"created_at TEXT NOT NULL, edited_at REAL NOT NULL, "
|
|
2086
2748
|
"ran INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (group_id, n))")
|
|
2749
|
+
# The workspace a dataset belongs to (docs/workspaces.md); its
|
|
2750
|
+
# group versions and archives co-scope through the dataset id. A
|
|
2751
|
+
# store from before workspaces backfills every row to the default.
|
|
2752
|
+
if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(datasets)")}:
|
|
2753
|
+
db.execute("ALTER TABLE datasets ADD COLUMN workspace TEXT")
|
|
2754
|
+
db.execute("UPDATE datasets SET workspace = ? WHERE workspace IS NULL", (store.default_ws,))
|
|
2087
2755
|
# Rows from an earlier version are converted once, in place: a
|
|
2088
2756
|
# dataset is typed in by hand and costly to re-enter, so it is
|
|
2089
2757
|
# upgraded rather than hidden (AGENTS.md's one exception). The
|
|
@@ -2139,8 +2807,11 @@ class Datasets:
|
|
|
2139
2807
|
newest one's number, which a pin may name, and 1 before any is kept,
|
|
2140
2808
|
the row's body being version 1 in waiting."""
|
|
2141
2809
|
parsed = upgrade_body(json.loads(r["body"]))
|
|
2810
|
+
# The grader rides in the summary: a run carries the profile each
|
|
2811
|
+
# linked group asks, and the page resolves a run from the list.
|
|
2142
2812
|
out = {"id": r["id"], "name": r["name"], "cases": len(parsed.get("cases") or []),
|
|
2143
|
-
"version": r["version"], "versions": versions, "updated": r["updated_at"]
|
|
2813
|
+
"version": r["version"], "versions": versions, "updated": r["updated_at"],
|
|
2814
|
+
"grader": parsed.get("grader")}
|
|
2144
2815
|
if body:
|
|
2145
2816
|
out["body"] = parsed
|
|
2146
2817
|
return out
|
|
@@ -2158,17 +2829,20 @@ class Datasets:
|
|
|
2158
2829
|
return closing(db)
|
|
2159
2830
|
|
|
2160
2831
|
def _live(self, db, did):
|
|
2161
|
-
return db.execute("SELECT * FROM datasets WHERE id = ? AND trash IS NULL",
|
|
2832
|
+
return db.execute("SELECT * FROM datasets WHERE id = ? AND trash IS NULL AND workspace = ?",
|
|
2833
|
+
(did, workspace_of(self.store))).fetchone()
|
|
2162
2834
|
|
|
2163
2835
|
def _names(self, db, but=None):
|
|
2164
|
-
return {r[0] for r in db.execute(
|
|
2165
|
-
|
|
2836
|
+
return {r[0] for r in db.execute(
|
|
2837
|
+
"SELECT name FROM datasets WHERE trash IS NULL AND id IS NOT ? AND workspace = ?",
|
|
2838
|
+
(but, workspace_of(self.store)))}
|
|
2166
2839
|
|
|
2167
2840
|
def list(self) -> list:
|
|
2168
2841
|
with self.store.lock, self._connect() as db:
|
|
2169
2842
|
counts = self._counts(db)
|
|
2170
2843
|
return [self._doc(r, False, counts.get(r["id"], 1)) for r in db.execute(
|
|
2171
|
-
"SELECT * FROM datasets WHERE trash IS NULL ORDER BY name COLLATE NOCASE, id"
|
|
2844
|
+
"SELECT * FROM datasets WHERE trash IS NULL AND workspace = ? ORDER BY name COLLATE NOCASE, id",
|
|
2845
|
+
(workspace_of(self.store),))]
|
|
2172
2846
|
|
|
2173
2847
|
def get(self, did):
|
|
2174
2848
|
with self.store.lock, self._connect() as db:
|
|
@@ -2198,11 +2872,11 @@ class Datasets:
|
|
|
2198
2872
|
"VALUES (?, 1, ?, ?, ?)", (r["id"], r["body"], r["updated_at"], edited))
|
|
2199
2873
|
return self._head(db, r["id"])
|
|
2200
2874
|
|
|
2201
|
-
|
|
2202
|
-
def _pins(db) -> set:
|
|
2875
|
+
def _pins(self, db) -> set:
|
|
2203
2876
|
"""Every (group, version) a stored pipeline pins, read in the caller's
|
|
2204
|
-
transaction from
|
|
2205
|
-
row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows'"
|
|
2877
|
+
transaction from this workspace's workflows document."""
|
|
2878
|
+
row = db.execute("SELECT body FROM docs WHERE name = 'promptlab.workflows' AND workspace = ?",
|
|
2879
|
+
(workspace_of(self.store),)).fetchone()
|
|
2206
2880
|
return pins_in(json.loads(row[0])) if row and row[0] else set()
|
|
2207
2881
|
|
|
2208
2882
|
def _cut(self, db, did, text):
|
|
@@ -2316,8 +2990,9 @@ class Datasets:
|
|
|
2316
2990
|
def _insert(self, db, name, body):
|
|
2317
2991
|
did = secrets.token_hex(6)
|
|
2318
2992
|
now = self._now()
|
|
2319
|
-
db.execute("INSERT INTO datasets (id, name, version, body, created_at, updated_at) "
|
|
2320
|
-
"VALUES (?, ?, 1, ?, ?, ?)",
|
|
2993
|
+
db.execute("INSERT INTO datasets (id, name, version, body, created_at, updated_at, workspace) "
|
|
2994
|
+
"VALUES (?, ?, 1, ?, ?, ?, ?)",
|
|
2995
|
+
(did, name, json.dumps(body), now, now, workspace_of(self.store)))
|
|
2321
2996
|
return did
|
|
2322
2997
|
|
|
2323
2998
|
def create(self, name, body=None):
|
|
@@ -2387,7 +3062,8 @@ class Datasets:
|
|
|
2387
3062
|
"""A trashed dataset back, under a new ` (2)` name if its own has been
|
|
2388
3063
|
taken since. Returns (DatasetDoc, None)."""
|
|
2389
3064
|
with self.store.lock, self._connect() as db, db:
|
|
2390
|
-
r = db.execute("SELECT * FROM datasets WHERE trash = ?",
|
|
3065
|
+
r = db.execute("SELECT * FROM datasets WHERE trash = ? AND workspace = ?",
|
|
3066
|
+
(str(token), workspace_of(self.store))).fetchone()
|
|
2391
3067
|
if r is None:
|
|
2392
3068
|
return None, (404, "no such trash entry")
|
|
2393
3069
|
name = unique_dataset_name(r["name"], self._names(db, but=r["id"]))
|
|
@@ -2423,8 +3099,8 @@ class Datasets:
|
|
|
2423
3099
|
|
|
2424
3100
|
def export_all(self):
|
|
2425
3101
|
with self.store.lock, self._connect() as db:
|
|
2426
|
-
rows = db.execute("SELECT * FROM datasets WHERE trash IS NULL "
|
|
2427
|
-
"ORDER BY name COLLATE NOCASE, id").fetchall()
|
|
3102
|
+
rows = db.execute("SELECT * FROM datasets WHERE trash IS NULL AND workspace = ? "
|
|
3103
|
+
"ORDER BY name COLLATE NOCASE, id", (workspace_of(self.store),)).fetchall()
|
|
2428
3104
|
return {"format": EXPORT_ALL, "version": EXPORT_VERSION,
|
|
2429
3105
|
"groups": [{"name": r["name"], "body": upgrade_body(json.loads(r["body"]))} for r in rows]}
|
|
2430
3106
|
|
|
@@ -2682,11 +3358,40 @@ class Packs:
|
|
|
2682
3358
|
self.trash = {}
|
|
2683
3359
|
with store.lock, closing(sqlite3.connect(store.path)) as db, db:
|
|
2684
3360
|
db.execute("CREATE TABLE IF NOT EXISTS packs ("
|
|
2685
|
-
"id TEXT
|
|
2686
|
-
"manifest TEXT NOT NULL, presets TEXT NOT NULL, installed_at TEXT NOT NULL
|
|
3361
|
+
"id TEXT NOT NULL, name TEXT NOT NULL, version TEXT NOT NULL, "
|
|
3362
|
+
"manifest TEXT NOT NULL, presets TEXT NOT NULL, installed_at TEXT NOT NULL, "
|
|
3363
|
+
"workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (id, workspace))")
|
|
2687
3364
|
db.execute("CREATE TABLE IF NOT EXISTS pack_items ("
|
|
2688
3365
|
"pack TEXT NOT NULL, kind TEXT NOT NULL, key TEXT NOT NULL, item TEXT NOT NULL, "
|
|
2689
|
-
"PRIMARY KEY (pack, kind, key))")
|
|
3366
|
+
"workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (pack, workspace, kind, key))")
|
|
3367
|
+
# A pack's id is its manifest's, not globally unique, so two
|
|
3368
|
+
# workspaces can hold the same pack: the workspace is part of both
|
|
3369
|
+
# keys (docs/workspaces.md). A store from before workspaces keyed
|
|
3370
|
+
# packs by id alone; it is rebuilt once, every row to the default.
|
|
3371
|
+
if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(packs)")}:
|
|
3372
|
+
self._rebuild(db, store.default_ws, "packs",
|
|
3373
|
+
"id, name, version, manifest, presets, installed_at",
|
|
3374
|
+
"id TEXT NOT NULL, name TEXT NOT NULL, version TEXT NOT NULL, "
|
|
3375
|
+
"manifest TEXT NOT NULL, presets TEXT NOT NULL, installed_at TEXT NOT NULL, "
|
|
3376
|
+
"workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (id, workspace)")
|
|
3377
|
+
if "workspace" not in {r[1] for r in db.execute("PRAGMA table_info(pack_items)")}:
|
|
3378
|
+
self._rebuild(db, store.default_ws, "pack_items", "pack, kind, key, item",
|
|
3379
|
+
"pack TEXT NOT NULL, kind TEXT NOT NULL, key TEXT NOT NULL, item TEXT NOT NULL, "
|
|
3380
|
+
"workspace TEXT NOT NULL DEFAULT '', PRIMARY KEY (pack, workspace, kind, key)")
|
|
3381
|
+
|
|
3382
|
+
@staticmethod
|
|
3383
|
+
def _rebuild(db, ws, table, cols, schema):
|
|
3384
|
+
"""A table keyed without a workspace, rebuilt with one its old rows
|
|
3385
|
+
take: SQLite cannot add to a PRIMARY KEY, so the rows move through a
|
|
3386
|
+
fresh table (the sources `type`/`config` migration's shape)."""
|
|
3387
|
+
rows = db.execute(f"SELECT {cols} FROM {table}").fetchall()
|
|
3388
|
+
db.execute(f"ALTER TABLE {table} RENAME TO {table}_old")
|
|
3389
|
+
db.execute(f"CREATE TABLE {table} ({schema})")
|
|
3390
|
+
names = cols.split(", ")
|
|
3391
|
+
ph = ", ".join("?" * (len(names) + 1))
|
|
3392
|
+
for r in rows:
|
|
3393
|
+
db.execute(f"INSERT INTO {table} ({cols}, workspace) VALUES ({ph})", (*r, ws))
|
|
3394
|
+
db.execute(f"DROP TABLE {table}_old")
|
|
2690
3395
|
|
|
2691
3396
|
def _connect(self):
|
|
2692
3397
|
db = sqlite3.connect(self.store.path)
|
|
@@ -2696,12 +3401,15 @@ class Packs:
|
|
|
2696
3401
|
def _items(self, pid):
|
|
2697
3402
|
with self.store.lock, self._connect() as db:
|
|
2698
3403
|
return {(r["kind"], r["key"]): r["item"]
|
|
2699
|
-
for r in db.execute("SELECT kind, key, item FROM pack_items WHERE pack = ?",
|
|
3404
|
+
for r in db.execute("SELECT kind, key, item FROM pack_items WHERE pack = ? AND workspace = ?",
|
|
3405
|
+
(pid, workspace_of(self.store)))}
|
|
2700
3406
|
|
|
2701
3407
|
def list(self) -> list:
|
|
2702
3408
|
with self.store.lock, self._connect() as db:
|
|
2703
|
-
|
|
2704
|
-
|
|
3409
|
+
ws = workspace_of(self.store)
|
|
3410
|
+
packs = db.execute("SELECT * FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
|
|
3411
|
+
(ws,)).fetchall()
|
|
3412
|
+
items = db.execute("SELECT pack, kind, item FROM pack_items WHERE workspace = ?", (ws,)).fetchall()
|
|
2705
3413
|
workflows = {w.get("id"): w.get("name") for w in self._doc("promptlab.workflows")[1].get("list", [])}
|
|
2706
3414
|
out = []
|
|
2707
3415
|
for p in packs:
|
|
@@ -2721,7 +3429,8 @@ class Packs:
|
|
|
2721
3429
|
def presets(self) -> list:
|
|
2722
3430
|
"""Every installed pack's Setup presets, each marked with its pack."""
|
|
2723
3431
|
with self.store.lock, self._connect() as db:
|
|
2724
|
-
rows = db.execute("SELECT id, presets FROM packs ORDER BY name COLLATE NOCASE"
|
|
3432
|
+
rows = db.execute("SELECT id, presets FROM packs WHERE workspace = ? ORDER BY name COLLATE NOCASE",
|
|
3433
|
+
(workspace_of(self.store),)).fetchall()
|
|
2725
3434
|
return [{**p, "pack": r["id"]} for r in rows for p in json.loads(r["presets"])]
|
|
2726
3435
|
|
|
2727
3436
|
# The page's documents are the server's to write here too, through the
|
|
@@ -2760,9 +3469,11 @@ class Packs:
|
|
|
2760
3469
|
why = pack_requires_problem(m, set(plugins))
|
|
2761
3470
|
if why:
|
|
2762
3471
|
return None, (400, why)
|
|
3472
|
+
ws = workspace_of(self.store)
|
|
2763
3473
|
with self.lock:
|
|
2764
3474
|
with self.store.lock, self._connect() as db:
|
|
2765
|
-
have = db.execute("SELECT version FROM packs WHERE id = ?
|
|
3475
|
+
have = db.execute("SELECT version FROM packs WHERE id = ? AND workspace = ?",
|
|
3476
|
+
(m["id"], ws)).fetchone()
|
|
2766
3477
|
if have and have["version"] == m["packVersion"]:
|
|
2767
3478
|
return {"pack": m["id"], "installed": False}, None
|
|
2768
3479
|
owned = self._items(m["id"])
|
|
@@ -2857,22 +3568,23 @@ class Packs:
|
|
|
2857
3568
|
self._write_doc("promptlab.versions", keep)
|
|
2858
3569
|
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
2859
3570
|
with self.store.lock, self._connect() as db, db:
|
|
2860
|
-
db.execute("INSERT INTO packs (id, name, version, manifest, presets, installed_at) "
|
|
2861
|
-
"VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
|
|
2862
|
-
"version = excluded.version, manifest = excluded.manifest, "
|
|
3571
|
+
db.execute("INSERT INTO packs (id, name, version, manifest, presets, installed_at, workspace) "
|
|
3572
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id, workspace) DO UPDATE SET "
|
|
3573
|
+
"name = excluded.name, version = excluded.version, manifest = excluded.manifest, "
|
|
2863
3574
|
"presets = excluded.presets, installed_at = excluded.installed_at",
|
|
2864
3575
|
(m["id"], m["name"].strip(), m["packVersion"], json.dumps(m),
|
|
2865
|
-
json.dumps(pack["presets"]), now))
|
|
3576
|
+
json.dumps(pack["presets"]), now, ws))
|
|
2866
3577
|
for (kind, key), item in made.items():
|
|
2867
|
-
db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item)
|
|
2868
|
-
(m["id"], kind, key, item))
|
|
3578
|
+
db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item, workspace) "
|
|
3579
|
+
"VALUES (?, ?, ?, ?, ?)", (m["id"], kind, key, item, ws))
|
|
2869
3580
|
return {"pack": m["id"], "installed": True}, None
|
|
2870
3581
|
|
|
2871
3582
|
def remove(self, pid):
|
|
2872
3583
|
"""What the pack made, into the trash, at once. Returns (token, None)."""
|
|
3584
|
+
ws = workspace_of(self.store)
|
|
2873
3585
|
with self.lock:
|
|
2874
3586
|
with self.store.lock, self._connect() as db:
|
|
2875
|
-
row = db.execute("SELECT * FROM packs WHERE id = ?", (pid,)).fetchone()
|
|
3587
|
+
row = db.execute("SELECT * FROM packs WHERE id = ? AND workspace = ?", (pid, ws)).fetchone()
|
|
2876
3588
|
if row is None:
|
|
2877
3589
|
return None, (404, "no such pack")
|
|
2878
3590
|
items = self._items(pid)
|
|
@@ -2897,8 +3609,8 @@ class Packs:
|
|
|
2897
3609
|
if gone:
|
|
2898
3610
|
self._write_doc("promptlab.workflows", drop)
|
|
2899
3611
|
with self.store.lock, self._connect() as db, db:
|
|
2900
|
-
db.execute("DELETE FROM pack_items WHERE pack = ?", (pid,))
|
|
2901
|
-
db.execute("DELETE FROM packs WHERE id = ?", (pid,))
|
|
3612
|
+
db.execute("DELETE FROM pack_items WHERE pack = ? AND workspace = ?", (pid, ws))
|
|
3613
|
+
db.execute("DELETE FROM packs WHERE id = ? AND workspace = ?", (pid, ws))
|
|
2902
3614
|
token = secrets.token_hex(6)
|
|
2903
3615
|
self.trash[token] = entry
|
|
2904
3616
|
return token, None
|
|
@@ -2918,13 +3630,14 @@ class Packs:
|
|
|
2918
3630
|
self._write_doc("promptlab.workflows",
|
|
2919
3631
|
lambda body: {**body, "list": [*body.get("list", []), *entry["pipelines"]]})
|
|
2920
3632
|
r = entry["row"]
|
|
3633
|
+
ws = r.get("workspace", self.store.default_ws)
|
|
2921
3634
|
with self.store.lock, self._connect() as db, db:
|
|
2922
|
-
db.execute("INSERT OR REPLACE INTO packs (id, name, version, manifest, presets, installed_at) "
|
|
2923
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
2924
|
-
(r["id"], r["name"], r["version"], r["manifest"], r["presets"], r["installed_at"]))
|
|
3635
|
+
db.execute("INSERT OR REPLACE INTO packs (id, name, version, manifest, presets, installed_at, workspace) "
|
|
3636
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
3637
|
+
(r["id"], r["name"], r["version"], r["manifest"], r["presets"], r["installed_at"], ws))
|
|
2925
3638
|
for kind, key, item in entry["items"]:
|
|
2926
|
-
db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item)
|
|
2927
|
-
(r["id"], kind, key, item))
|
|
3639
|
+
db.execute("INSERT OR REPLACE INTO pack_items (pack, kind, key, item, workspace) "
|
|
3640
|
+
"VALUES (?, ?, ?, ?, ?)", (r["id"], kind, key, item, ws))
|
|
2928
3641
|
del self.trash[token]
|
|
2929
3642
|
return {"pack": r["id"]}, None
|
|
2930
3643
|
|
|
@@ -2935,7 +3648,8 @@ class Packs:
|
|
|
2935
3648
|
removed it and has datasets of its own is left alone. Returns what
|
|
2936
3649
|
happened, or None where nothing was tried."""
|
|
2937
3650
|
with self.store.lock, self._connect() as db:
|
|
2938
|
-
have = db.execute("SELECT version FROM packs WHERE id = 'demo'"
|
|
3651
|
+
have = db.execute("SELECT version FROM packs WHERE id = 'demo' AND workspace = ?",
|
|
3652
|
+
(workspace_of(self.store),)).fetchone()
|
|
2939
3653
|
if have is None and DATASETS.list():
|
|
2940
3654
|
return None
|
|
2941
3655
|
got, err = self.install(demo_pack())
|
|
@@ -2975,7 +3689,7 @@ PLUGIN_VERSIONS = (1,)
|
|
|
2975
3689
|
PLUGIN_CAP = int(os.environ.get("PLUGIN_CAP", str(16 * 1024 ** 2)))
|
|
2976
3690
|
PLUGIN_VERSION_TEXT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
|
2977
3691
|
PLUGIN_FILE = re.compile(r"(?:[A-Za-z0-9_-][A-Za-z0-9._-]*/)*[A-Za-z0-9_-][A-Za-z0-9._-]*\.(?:js|mjs|json|map)")
|
|
2978
|
-
REGISTRIES = ("outputKinds", "modifiers", "evalTypes", "connectionTypes")
|
|
3692
|
+
REGISTRIES = ("outputKinds", "modifiers", "evalTypes", "connectionTypes", "workflowPlatforms", "wizards")
|
|
2979
3693
|
# evalTypes as a manifest written before pipeline version 11 spells it: a
|
|
2980
3694
|
# plugin's own file, which the lab cannot upgrade, so it is read for good.
|
|
2981
3695
|
OLD_REGISTRIES = {"testTypes": "evalTypes"}
|
|
@@ -3029,7 +3743,7 @@ def read_plugin(data: bytes):
|
|
|
3029
3743
|
reg = m.get("registers") or {}
|
|
3030
3744
|
if not isinstance(reg, dict) or any(k not in REGISTRIES and k not in OLD_REGISTRIES for k in reg):
|
|
3031
3745
|
return None, f"a plugin's registers are {', '.join(REGISTRIES)}"
|
|
3032
|
-
for k in ("outputKinds", "modifiers", "evalTypes", *OLD_REGISTRIES):
|
|
3746
|
+
for k in ("outputKinds", "modifiers", "evalTypes", "wizards", *OLD_REGISTRIES):
|
|
3033
3747
|
if not isinstance(reg.get(k, []), list) or not all(isinstance(x, str) and x for x in reg.get(k, [])):
|
|
3034
3748
|
return None, f"registers.{k} is a list of ids"
|
|
3035
3749
|
conns = reg.get("connectionTypes", [])
|
|
@@ -3044,6 +3758,25 @@ def read_plugin(data: bytes):
|
|
|
3044
3758
|
or c.get("auth", "bearer") not in AUTH_WAYS):
|
|
3045
3759
|
return None, ("a connection type the plugin registers is { id, settings, chatPath, "
|
|
3046
3760
|
f"auth }}, auth one of {', '.join(AUTH_WAYS)}")
|
|
3761
|
+
# A workflow platform (#303): the generic `workflow` kind drives it, and the
|
|
3762
|
+
# server reads only the data it enforces -- the files it takes, whether it
|
|
3763
|
+
# keeps a definition, and the sign-in it is made with. The sign-in must be
|
|
3764
|
+
# one this lab already has (or none): a platform needing a new server-held
|
|
3765
|
+
# OAuth grant is server code and a secret store a plugin cannot ship, so it
|
|
3766
|
+
# stays lab-only (docs/workflow-sources.md phase 7).
|
|
3767
|
+
platforms = reg.get("workflowPlatforms", [])
|
|
3768
|
+
if not isinstance(platforms, list):
|
|
3769
|
+
return None, "registers.workflowPlatforms is a list"
|
|
3770
|
+
for p in platforms:
|
|
3771
|
+
uploads = p.get("uploads", {}) if isinstance(p, dict) else None
|
|
3772
|
+
if (not isinstance(p, dict) or not isinstance(p.get("id"), str) or not p["id"]
|
|
3773
|
+
or not isinstance(uploads, dict)
|
|
3774
|
+
or not all(isinstance(e, str) and e.startswith(".") and isinstance(t, str)
|
|
3775
|
+
for e, t in uploads.items())
|
|
3776
|
+
or not isinstance(p.get("keepsDefinition", False), bool)
|
|
3777
|
+
or (p.get("signIn") is not None and p.get("signIn") not in SIGN_INS)):
|
|
3778
|
+
return None, ("a workflow platform the plugin registers is { id, uploads, "
|
|
3779
|
+
"keepsDefinition, signIn }, signIn null or a sign-in the lab has")
|
|
3047
3780
|
why = pack_requires_problem({"requires": {"lab": (m.get("requires") or {}).get("lab")}}, set())
|
|
3048
3781
|
if why:
|
|
3049
3782
|
return None, why.replace("the pack", "the plugin")
|
|
@@ -3053,8 +3786,9 @@ def read_plugin(data: bytes):
|
|
|
3053
3786
|
def registered_ids(manifest: dict) -> set:
|
|
3054
3787
|
"""(registry, id) for everything a plugin's manifest says it registers."""
|
|
3055
3788
|
reg = plugin_registers(manifest)
|
|
3056
|
-
out = {(k, x) for k in ("outputKinds", "modifiers", "evalTypes") for x in reg.get(k, [])}
|
|
3057
|
-
|
|
3789
|
+
out = {(k, x) for k in ("outputKinds", "modifiers", "evalTypes", "wizards") for x in reg.get(k, [])}
|
|
3790
|
+
out |= {("connectionTypes", c["id"]) for c in reg.get("connectionTypes", [])}
|
|
3791
|
+
return out | {("workflowPlatforms", p["id"]) for p in reg.get("workflowPlatforms", [])}
|
|
3058
3792
|
|
|
3059
3793
|
|
|
3060
3794
|
# ---- Connections: the lab's grants to outside services (#127) --------------
|
|
@@ -3385,20 +4119,35 @@ class Plugins:
|
|
|
3385
4119
|
return [dict(r) for r in db.execute("SELECT * FROM plugins ORDER BY id")]
|
|
3386
4120
|
|
|
3387
4121
|
def apply(self):
|
|
3388
|
-
"""The server's connection-type
|
|
3389
|
-
installed plugin's, rebuilt in place so every
|
|
4122
|
+
"""The server's connection-type and workflow-platform mirrors: the
|
|
4123
|
+
built-in entries and every installed plugin's, rebuilt in place so every
|
|
4124
|
+
reader sees the same."""
|
|
3390
4125
|
types, paths, auth = dict(BUILTIN_CONNECTION_TYPES), dict(BUILTIN_CHAT_PATHS), dict(BUILTIN_AUTH)
|
|
3391
4126
|
local = set(BUILTIN_LOCAL)
|
|
4127
|
+
platforms = {k: dict(v) for k, v in BUILTIN_WORKFLOW_PLATFORMS.items()}
|
|
3392
4128
|
for r in self._rows():
|
|
3393
|
-
|
|
4129
|
+
reg = json.loads(r["manifest"]).get("registers") or {}
|
|
4130
|
+
for c in reg.get("connectionTypes", []):
|
|
3394
4131
|
types[c["id"]] = tuple(c.get("settings", []))
|
|
3395
4132
|
paths[c["id"]] = c.get("chatPath", "/chat/completions")
|
|
3396
4133
|
auth[c["id"]] = c.get("auth", "bearer")
|
|
3397
4134
|
if c.get("local") is True:
|
|
3398
4135
|
local.add(c["id"])
|
|
4136
|
+
# A plugin platform's enforcement data, read from the manifest: the
|
|
4137
|
+
# files it takes, checked and redacted by the generic record
|
|
4138
|
+
# redactor like any flow's (take_record), whether it keeps a
|
|
4139
|
+
# definition, and the sign-in (none, or one the lab has) a Source of
|
|
4140
|
+
# it is made with. Its code -- api, stepsOf, evaluate … -- is the
|
|
4141
|
+
# page's and the runner's; this server never runs it.
|
|
4142
|
+
for p in reg.get("workflowPlatforms", []):
|
|
4143
|
+
platforms[p["id"]] = {"label": p.get("label", p["id"]), "take": take_record,
|
|
4144
|
+
"uploads": p.get("uploads") or {},
|
|
4145
|
+
"definition": bool(p.get("keepsDefinition")),
|
|
4146
|
+
"signIn": p.get("signIn")}
|
|
3399
4147
|
LOCAL_CONNECTIONS.clear()
|
|
3400
4148
|
LOCAL_CONNECTIONS.update(local)
|
|
3401
|
-
for table, value in ((CONNECTION_TYPES, types), (CONNECTION_CHAT_PATHS, paths),
|
|
4149
|
+
for table, value in ((CONNECTION_TYPES, types), (CONNECTION_CHAT_PATHS, paths),
|
|
4150
|
+
(CONNECTION_AUTH, auth), (WORKFLOW_PLATFORMS, platforms)):
|
|
3402
4151
|
table.clear()
|
|
3403
4152
|
table.update(value)
|
|
3404
4153
|
|
|
@@ -3448,9 +4197,13 @@ class Plugins:
|
|
|
3448
4197
|
if same and same["sha256"] == plugin["sha256"]:
|
|
3449
4198
|
return {"plugin": m["id"], "installed": False}, None
|
|
3450
4199
|
mine = registered_ids(m)
|
|
4200
|
+
builtin = {"connectionTypes": (BUILTIN_CONNECTION_TYPES, "connection type"),
|
|
4201
|
+
"workflowPlatforms": (BUILTIN_WORKFLOW_PLATFORMS, "workflow platform"),
|
|
4202
|
+
"wizards": (BUILTIN_WIZARD_IDS, "wizard")}
|
|
3451
4203
|
for (reg, x) in mine:
|
|
3452
|
-
|
|
3453
|
-
|
|
4204
|
+
have, what = builtin.get(reg, (None, None))
|
|
4205
|
+
if have is not None and x in have:
|
|
4206
|
+
return None, (400, f"the plugin registers the {what} {x}, which the lab has already")
|
|
3454
4207
|
for r in rows:
|
|
3455
4208
|
if r["id"] == m["id"]:
|
|
3456
4209
|
continue
|
|
@@ -3704,6 +4457,14 @@ class Queue:
|
|
|
3704
4457
|
# `dataset` column, read as its one group.
|
|
3705
4458
|
if "groups" not in cols:
|
|
3706
4459
|
db.execute("ALTER TABLE queue ADD COLUMN groups TEXT")
|
|
4460
|
+
# The workspace a run belongs to (docs/workspaces.md): History is
|
|
4461
|
+
# per-workspace, so the list and every id-keyed read filter by it.
|
|
4462
|
+
# The worker loop is the one global reader -- it grades every
|
|
4463
|
+
# workspace's runs by id. A store from before workspaces backfills
|
|
4464
|
+
# to the default.
|
|
4465
|
+
if "workspace" not in cols:
|
|
4466
|
+
db.execute("ALTER TABLE queue ADD COLUMN workspace TEXT")
|
|
4467
|
+
db.execute("UPDATE queue SET workspace = ? WHERE workspace IS NULL", (store.default_ws,))
|
|
3707
4468
|
|
|
3708
4469
|
# ---- rows -----------------------------------------------------------
|
|
3709
4470
|
|
|
@@ -3713,7 +4474,7 @@ class Queue:
|
|
|
3713
4474
|
# the order ALTER TABLE added them in is no order _row can count on.
|
|
3714
4475
|
SELECT = ("SELECT q.id, q.status, q.cancel, q.submitted_at, q.started_at, "
|
|
3715
4476
|
"q.finished_at, q.snapshot, q.results, q.progress, q.totals, q.error, "
|
|
3716
|
-
"q.rerun_of, q.verdict, q.verdicts, o.submitted_at FROM queue q "
|
|
4477
|
+
"q.rerun_of, q.verdict, q.verdicts, o.submitted_at, q.workspace FROM queue q "
|
|
3717
4478
|
"LEFT JOIN queue o ON o.id = q.rerun_of")
|
|
3718
4479
|
|
|
3719
4480
|
@staticmethod
|
|
@@ -3729,6 +4490,7 @@ class Queue:
|
|
|
3729
4490
|
"rerunOf": r[11], "rerunOfAt": r[14],
|
|
3730
4491
|
"verdict": r[12],
|
|
3731
4492
|
"verdicts": json.loads(r[13]) if r[13] else None,
|
|
4493
|
+
"workspace": r[15],
|
|
3732
4494
|
}
|
|
3733
4495
|
|
|
3734
4496
|
# A row from before run documents has no version, and nothing here can
|
|
@@ -3741,14 +4503,20 @@ class Queue:
|
|
|
3741
4503
|
def _readable(row):
|
|
3742
4504
|
return row is not None and (row["snapshot"] or {}).get("version") in READABLE_VERSIONS
|
|
3743
4505
|
|
|
3744
|
-
def _all(self, db):
|
|
3745
|
-
|
|
4506
|
+
def _all(self, db, ws=None):
|
|
4507
|
+
"""Every readable run; a workspace's when `ws` is given, else the lot --
|
|
4508
|
+
the worker and startup's prompt backfill read across every workspace."""
|
|
4509
|
+
sql = self.SELECT + (" WHERE q.workspace = ?" if ws else "")
|
|
4510
|
+
return [row for row in (self._row(r) for r in db.execute(sql, (ws,) if ws else ()))
|
|
3746
4511
|
if self._readable(row)]
|
|
3747
4512
|
|
|
3748
|
-
def get(self, rid):
|
|
4513
|
+
def get(self, rid, ws=None):
|
|
4514
|
+
"""One run by id, or None. `ws` scopes the lookup so a workspace cannot
|
|
4515
|
+
read another's run by id (docs/workspaces.md); the worker reads
|
|
4516
|
+
unscoped, by the globally unique id."""
|
|
4517
|
+
sql = self.SELECT + " WHERE q.id = ?" + (" AND q.workspace = ?" if ws else "")
|
|
3749
4518
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
3750
|
-
row = self._row(db.execute(
|
|
3751
|
-
(rid,)).fetchone())
|
|
4519
|
+
row = self._row(db.execute(sql, (rid, ws) if ws else (rid,)).fetchone())
|
|
3752
4520
|
return row if self._readable(row) else None
|
|
3753
4521
|
|
|
3754
4522
|
def list(self, limit=RUNS_PAGE, before=None, before_id=None, full=False):
|
|
@@ -3760,7 +4528,7 @@ class Queue:
|
|
|
3760
4528
|
second (#238). `before` alone stops at the second. Each row is
|
|
3761
4529
|
brief_row's unless `full` asks for the whole of it."""
|
|
3762
4530
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db:
|
|
3763
|
-
rows = self._all(db)
|
|
4531
|
+
rows = self._all(db, workspace_of(self.store))
|
|
3764
4532
|
key = lambda r: (r["submittedAt"], r["id"])
|
|
3765
4533
|
rows = [r for r in rows if before is None or key(r) < (before, before_id or "")]
|
|
3766
4534
|
rows.sort(key=key, reverse=True)
|
|
@@ -3791,13 +4559,13 @@ class Queue:
|
|
|
3791
4559
|
total = len(run_items(run))
|
|
3792
4560
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
3793
4561
|
db.execute("INSERT INTO queue (id, status, cancel, submitted_at, "
|
|
3794
|
-
"snapshot, results, progress, totals, dataset, rerun_of, groups) "
|
|
3795
|
-
"VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
4562
|
+
"snapshot, results, progress, totals, dataset, rerun_of, groups, workspace) "
|
|
4563
|
+
"VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
3796
4564
|
(rid, "queued", now, json.dumps(run), "[]",
|
|
3797
4565
|
json.dumps({"current": None, "n": 0, "total": total}),
|
|
3798
4566
|
json.dumps({"ran": 0, "passed": 0, "found": 0, "of": 0}),
|
|
3799
4567
|
None if dataset is None else json.dumps(dataset), rerun_of,
|
|
3800
|
-
None if groups is None else json.dumps(groups)))
|
|
4568
|
+
None if groups is None else json.dumps(groups), workspace_of(self.store)))
|
|
3801
4569
|
# Its prompts' uses, in the same transaction: a run is in the
|
|
3802
4570
|
# library the moment it is queued, or not queued at all.
|
|
3803
4571
|
if self.prompts is not None:
|
|
@@ -3963,12 +4731,15 @@ class Queue:
|
|
|
3963
4731
|
return self.get(rid), None
|
|
3964
4732
|
|
|
3965
4733
|
def clear(self):
|
|
3966
|
-
"""Empty
|
|
3967
|
-
item files go too, so a cleared run leaves nothing behind
|
|
3968
|
-
re-dequeued by a stray worker."""
|
|
4734
|
+
"""Empty this workspace's History: its runs, in every browser. The
|
|
4735
|
+
materialised item files go too, so a cleared run leaves nothing behind
|
|
4736
|
+
to be re-dequeued by a stray worker. Another workspace's runs stay."""
|
|
4737
|
+
ws = workspace_of(self.store)
|
|
3969
4738
|
with self.lock, closing(sqlite3.connect(self.store.path)) as db, db:
|
|
3970
|
-
|
|
3971
|
-
|
|
4739
|
+
gone = [r[0] for r in db.execute("SELECT id FROM queue WHERE workspace = ?", (ws,))]
|
|
4740
|
+
n = db.execute("DELETE FROM queue WHERE workspace = ?", (ws,)).rowcount
|
|
4741
|
+
for rid in gone:
|
|
4742
|
+
d = self.dir / rid
|
|
3972
4743
|
if d.is_dir():
|
|
3973
4744
|
(d / "cancel").unlink(missing_ok=True)
|
|
3974
4745
|
shutil.rmtree(d, ignore_errors=True)
|
|
@@ -4364,12 +5135,19 @@ class Queue:
|
|
|
4364
5135
|
if run is None:
|
|
4365
5136
|
self._stop.wait(QUEUE_WAIT)
|
|
4366
5137
|
continue
|
|
5138
|
+
# The worker grades every workspace's runs; while it grades this
|
|
5139
|
+
# one, the thread is bound to the run's workspace, so any dataset
|
|
5140
|
+
# or Source it resolves (a legacy row's pinned body) reads from
|
|
5141
|
+
# there, not the default (docs/workspaces.md).
|
|
5142
|
+
set_workspace(run.get("workspace"))
|
|
4367
5143
|
try:
|
|
4368
5144
|
self._execute(run)
|
|
4369
5145
|
except Exception as e:
|
|
4370
5146
|
# The type, never the message -- the relay's rule, for the
|
|
4371
5147
|
# relay's reason: an exception message can carry a key.
|
|
4372
5148
|
self._finish(run["id"], "failed", error=f"the worker failed: {type(e).__name__}")
|
|
5149
|
+
finally:
|
|
5150
|
+
set_workspace(None)
|
|
4373
5151
|
|
|
4374
5152
|
def _dequeue(self):
|
|
4375
5153
|
"""The oldest queued run this server can read. One it cannot is never
|
|
@@ -4505,10 +5283,40 @@ def build_bundle(payload):
|
|
|
4505
5283
|
return buf.getvalue(), None
|
|
4506
5284
|
|
|
4507
5285
|
|
|
5286
|
+
# Shared-by-choice enforcement (docs/workspaces.md): the one sentence the Run
|
|
5287
|
+
# bar turns into a Share link, and the profiles that earn it -- the run's
|
|
5288
|
+
# Target profiles not shared with workspace `ws`. A local profile reaches
|
|
5289
|
+
# nothing, so sharing does not gate it. Keyed by the run's profiles table,
|
|
5290
|
+
# whose keys are the profile ids the share set is written against.
|
|
5291
|
+
def not_shared_sentence(names, wsname: str) -> str:
|
|
5292
|
+
verb = "is" if len(names) == 1 else "are"
|
|
5293
|
+
return f"{', '.join(names)} {verb} not shared with {wsname}."
|
|
5294
|
+
|
|
5295
|
+
|
|
5296
|
+
def unshared_profiles(run: dict, ws):
|
|
5297
|
+
table = run.get("profiles") if isinstance(run, dict) else None
|
|
5298
|
+
if not isinstance(table, dict) or STORE is None:
|
|
5299
|
+
return []
|
|
5300
|
+
out = []
|
|
5301
|
+
for pid, conn in table.items():
|
|
5302
|
+
if isinstance(conn, dict) and conn.get("type") in LOCAL_CONNECTIONS:
|
|
5303
|
+
continue
|
|
5304
|
+
if not STORE.shared("profile", pid, ws):
|
|
5305
|
+
out.append({"id": pid, "name": (isinstance(conn, dict) and conn.get("name")) or pid})
|
|
5306
|
+
return out
|
|
5307
|
+
|
|
5308
|
+
|
|
4508
5309
|
def worker_destinations(run: dict):
|
|
4509
5310
|
table = run.get("profiles") if isinstance(run, dict) else None
|
|
4510
5311
|
if not isinstance(table, dict):
|
|
4511
5312
|
return None, "a run document carries its profiles as an object of id → connection"
|
|
5313
|
+
# The run grades in the workspace the thread is bound to -- the request's
|
|
5314
|
+
# at submit, the run's at dequeue/re-run (docs/workspaces.md). A profile
|
|
5315
|
+
# not shared with it is refused here, the seam every run path passes.
|
|
5316
|
+
ws = workspace_of(STORE) if STORE is not None else None
|
|
5317
|
+
bad = unshared_profiles(run, ws)
|
|
5318
|
+
if bad:
|
|
5319
|
+
return None, not_shared_sentence([b["name"] for b in bad], STORE.workspace_name(ws))
|
|
4512
5320
|
stored = []
|
|
4513
5321
|
if STORE is not None:
|
|
4514
5322
|
doc = STORE.all().get("promptlab.profiles") or {}
|
|
@@ -4920,6 +5728,7 @@ def run_problems(run):
|
|
|
4920
5728
|
# there was a store, and it stays usable without one being asked for.
|
|
4921
5729
|
if DATA_DIR:
|
|
4922
5730
|
STORE = Store(Path(DATA_DIR) / "lab.db")
|
|
5731
|
+
WORKSPACES = Workspaces(STORE)
|
|
4923
5732
|
SOURCES = Sources(STORE)
|
|
4924
5733
|
PROMPTS = Prompts(STORE)
|
|
4925
5734
|
DATASETS = Datasets(STORE, PROMPTS)
|
|
@@ -4931,7 +5740,7 @@ if DATA_DIR:
|
|
|
4931
5740
|
QUEUE = Queue(STORE)
|
|
4932
5741
|
QUEUE.prompts = PROMPTS
|
|
4933
5742
|
else:
|
|
4934
|
-
STORE = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
|
|
5743
|
+
STORE = WORKSPACES = SOURCES = PROMPTS = DATASETS = PACKS = PLUGINS = CONNECTIONS = MICROSOFT = GOOGLE = QUEUE = None
|
|
4935
5744
|
|
|
4936
5745
|
|
|
4937
5746
|
class NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
@@ -5058,7 +5867,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5058
5867
|
# Runs now execute on the server and nowhere else, so the page
|
|
5059
5868
|
# says what this one is missing, instead of a request failing
|
|
5060
5869
|
# oddly mid-run.
|
|
5061
|
-
head += carried("labstate", {"docs": STORE.served(),
|
|
5870
|
+
head += carried("labstate", {"docs": STORE.served(self.ws),
|
|
5062
5871
|
"runs": {"queue": QUEUE is not None, "node": NODE is not None,
|
|
5063
5872
|
"convert": CONVERT is not None}})
|
|
5064
5873
|
# The page marks where the carried data goes (web/index.html); the
|
|
@@ -5082,9 +5891,39 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5082
5891
|
if self.grouped:
|
|
5083
5892
|
self.path = "/api/datasets" + self.path[len(self.GROUPS_ROUTE):]
|
|
5084
5893
|
|
|
5894
|
+
# The workspace a request is in (docs/workspaces.md), the default when it
|
|
5895
|
+
# names none. Set it to the store's flagged default even with no store, so
|
|
5896
|
+
# the None-store guards below read a harmless value.
|
|
5897
|
+
ws = None
|
|
5898
|
+
|
|
5899
|
+
def _scope(self):
|
|
5900
|
+
"""Resolve and bind the request's workspace: the `/w/<slug>` path
|
|
5901
|
+
prefix, else the `X-Workspace` header, else the flagged default. The
|
|
5902
|
+
prefix is stripped from self.path so every route below is addressed the
|
|
5903
|
+
same way inside a workspace or out of it -- `/w/<slug>` becomes `/`,
|
|
5904
|
+
which serves the SPA shell, and `/w/<slug>/api/...` becomes `/api/...`.
|
|
5905
|
+
An unknown slug falls back to the default; the page's Gone state for a
|
|
5906
|
+
vanished workspace is phase 3. The slug is a bookmarkable address, not a
|
|
5907
|
+
secret: isolation here is a filter, not access control."""
|
|
5908
|
+
raw = self.path
|
|
5909
|
+
qpos = raw.find("?")
|
|
5910
|
+
path, query = (raw[:qpos], raw[qpos:]) if qpos >= 0 else (raw, "")
|
|
5911
|
+
slug = None
|
|
5912
|
+
if path == "/w" or path.startswith("/w/"):
|
|
5913
|
+
slug, sep, tail = path[3:].partition("/")
|
|
5914
|
+
self.path = ("/" + tail if sep or tail else "/") + query
|
|
5915
|
+
ws = None
|
|
5916
|
+
if STORE is not None:
|
|
5917
|
+
named = slug or self.headers.get("X-Workspace")
|
|
5918
|
+
ws = STORE.workspace(named) if named else None
|
|
5919
|
+
ws = ws or STORE.default_ws
|
|
5920
|
+
self.ws = ws
|
|
5921
|
+
set_workspace(ws)
|
|
5922
|
+
|
|
5085
5923
|
def do_GET(self):
|
|
5086
5924
|
if not self._authorised():
|
|
5087
5925
|
return
|
|
5926
|
+
self._scope()
|
|
5088
5927
|
self._alias()
|
|
5089
5928
|
path = self.path.split("?", 1)[0]
|
|
5090
5929
|
# The lab is one page: a Connection and an Input make a scenario,
|
|
@@ -5120,7 +5959,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5120
5959
|
if path == "/api/state":
|
|
5121
5960
|
if STORE is None:
|
|
5122
5961
|
return self._send(404, b"not found", "text/plain")
|
|
5123
|
-
return self._json(200, {"docs": STORE.served()})
|
|
5962
|
+
return self._json(200, {"docs": STORE.served(self.ws)})
|
|
5124
5963
|
# The run queue (#530): a run is a row the server owns, and History
|
|
5125
5964
|
# reads the server. One run, or the list.
|
|
5126
5965
|
if path == "/api/queue":
|
|
@@ -5139,7 +5978,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5139
5978
|
# The bodies of the eval groups a run grades with, by `<id>@<n>`
|
|
5140
5979
|
# (§17); null for a run that kept none, as /dataset answers.
|
|
5141
5980
|
run_id = path.split("/")[3]
|
|
5142
|
-
if QUEUE is None or QUEUE.get(run_id) is None:
|
|
5981
|
+
if QUEUE is None or QUEUE.get(run_id, self.ws) is None:
|
|
5143
5982
|
return self._send(404, b"not found", "text/plain")
|
|
5144
5983
|
return self._json(200, QUEUE.groups(run_id))
|
|
5145
5984
|
if path.startswith("/api/queue/") and path.endswith("/dataset") and path.count("/") == 4:
|
|
@@ -5153,13 +5992,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5153
5992
|
# time such a run was opened, which is the console people are
|
|
5154
5993
|
# told to watch for real faults. A run that is not there is 404.
|
|
5155
5994
|
run_id = path.split("/")[3]
|
|
5156
|
-
if QUEUE is None or QUEUE.get(run_id) is None:
|
|
5995
|
+
if QUEUE is None or QUEUE.get(run_id, self.ws) is None:
|
|
5157
5996
|
return self._send(404, b"not found", "text/plain")
|
|
5158
5997
|
return self._json(200, QUEUE.dataset(run_id))
|
|
5159
5998
|
if path.startswith("/api/queue/"):
|
|
5160
5999
|
if QUEUE is None:
|
|
5161
6000
|
return self._send(404, b"not found", "text/plain")
|
|
5162
|
-
run = QUEUE.get(path[len("/api/queue/"):])
|
|
6001
|
+
run = QUEUE.get(path[len("/api/queue/"):], self.ws)
|
|
5163
6002
|
if run is None:
|
|
5164
6003
|
return self._send(404, b"not found", "text/plain")
|
|
5165
6004
|
# How far back in the queue this submission sits, for the form's
|
|
@@ -5175,6 +6014,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5175
6014
|
if PLUGINS is None:
|
|
5176
6015
|
return self._send(404, b"not found", "text/plain")
|
|
5177
6016
|
return self._json(200, {"plugins": PLUGINS.list(), "cap": PLUGIN_CAP})
|
|
6017
|
+
if path == "/api/connections/shares":
|
|
6018
|
+
# Which workspaces may use each Target profile and Account
|
|
6019
|
+
# (docs/workspaces.md): the share sets the Connections menus read.
|
|
6020
|
+
# Keyed by "<kind>:<id>", workspace ids and the sentinels, no key.
|
|
6021
|
+
if STORE is None:
|
|
6022
|
+
return self._send(404, b"not found", "text/plain")
|
|
6023
|
+
return self._json(200, {"shares": STORE.shares()})
|
|
5178
6024
|
if path == "/api/connections":
|
|
5179
6025
|
if CONNECTIONS is None:
|
|
5180
6026
|
return self._send(404, b"not found", "text/plain")
|
|
@@ -5199,6 +6045,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5199
6045
|
if PACKS is None:
|
|
5200
6046
|
return self._send(404, b"not found", "text/plain")
|
|
5201
6047
|
return self._json(200, {"packs": PACKS.list(), "cap": PACK_CAP})
|
|
6048
|
+
if path == "/api/workspaces":
|
|
6049
|
+
if WORKSPACES is None:
|
|
6050
|
+
return self._send(404, b"not found", "text/plain")
|
|
6051
|
+
WORKSPACES.lazy_trash()
|
|
6052
|
+
return self._json(200, {"workspaces": WORKSPACES.list()})
|
|
5202
6053
|
if path.startswith("/api/samples/thumbs/") or path.startswith("/api/samples/zoom/"):
|
|
5203
6054
|
# The sample-library tiles, store-independent: baked into the
|
|
5204
6055
|
# image (or absent on a checkout), and read by the library strip,
|
|
@@ -5303,6 +6154,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5303
6154
|
def do_DELETE(self):
|
|
5304
6155
|
if not self._authorised() or not self._from_this_page():
|
|
5305
6156
|
return
|
|
6157
|
+
self._scope()
|
|
5306
6158
|
self._alias()
|
|
5307
6159
|
path = self.path.split("?", 1)[0]
|
|
5308
6160
|
if path == "/api/connections/google":
|
|
@@ -5326,6 +6178,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5326
6178
|
if err:
|
|
5327
6179
|
return self._json(err[0], {"error": err[1]})
|
|
5328
6180
|
return self._json(200, {"trash": token})
|
|
6181
|
+
if path.startswith("/api/workspaces/"):
|
|
6182
|
+
parts = path.split("/")
|
|
6183
|
+
if WORKSPACES is None or len(parts) != 4:
|
|
6184
|
+
return self._send(404, b"not found", "text/plain")
|
|
6185
|
+
token, err = WORKSPACES.remove(parts[3])
|
|
6186
|
+
if err:
|
|
6187
|
+
return self._json(err[0], {"error": err[1]})
|
|
6188
|
+
return self._json(200, {"trash": token})
|
|
5329
6189
|
if path.startswith("/api/datasets/"):
|
|
5330
6190
|
parts = path.split("/")
|
|
5331
6191
|
if DATASETS is None or len(parts) != 4:
|
|
@@ -5371,6 +6231,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5371
6231
|
def do_PATCH(self):
|
|
5372
6232
|
if not self._authorised() or not self._from_this_page():
|
|
5373
6233
|
return
|
|
6234
|
+
self._scope()
|
|
5374
6235
|
self._alias()
|
|
5375
6236
|
parts = self.path.split("?", 1)[0].split("/")
|
|
5376
6237
|
if len(parts) != 4 or parts[1] != "api":
|
|
@@ -5392,18 +6253,37 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5392
6253
|
return self._json(200, source)
|
|
5393
6254
|
if parts[2] == "queue" and QUEUE is not None:
|
|
5394
6255
|
payload = self._payload() or {}
|
|
6256
|
+
if QUEUE.get(parts[3], self.ws) is None:
|
|
6257
|
+
return self._send(404, b"not found", "text/plain")
|
|
5395
6258
|
run, err = QUEUE.set_comment(parts[3], payload.get("comment"))
|
|
5396
6259
|
if err:
|
|
5397
6260
|
code, message = err
|
|
5398
6261
|
return self._json(code, {"error": message})
|
|
5399
6262
|
return self._json(200, run)
|
|
6263
|
+
if parts[2] == "workspaces" and WORKSPACES is not None:
|
|
6264
|
+
payload = self._payload() or {}
|
|
6265
|
+
wid, err = WORKSPACES.rename(parts[3], payload.get("name"))
|
|
6266
|
+
if err:
|
|
6267
|
+
return self._json(err[0], {"error": err[1]})
|
|
6268
|
+
return self._json(200, {"workspace": WORKSPACES.get(wid), "workspaces": WORKSPACES.list()})
|
|
5400
6269
|
return self._send(404, b"not found", "text/plain")
|
|
5401
6270
|
|
|
5402
6271
|
def do_PUT(self):
|
|
5403
6272
|
if not self._authorised() or not self._from_this_page():
|
|
5404
6273
|
return
|
|
6274
|
+
self._scope()
|
|
5405
6275
|
self._alias()
|
|
5406
6276
|
parts = self.path.split("?", 1)[0].split("/")
|
|
6277
|
+
if parts == ["", "api", "connections", "shares"] and STORE is not None:
|
|
6278
|
+
# A connection's whole share set, replaced (docs/workspaces.md): the
|
|
6279
|
+
# Connections checkbox menu's All / list / New workspaces. The reply
|
|
6280
|
+
# is the whole map, so every menu redraws from one response.
|
|
6281
|
+
payload = self._payload() or {}
|
|
6282
|
+
target = self._share_target(payload)
|
|
6283
|
+
if target is None:
|
|
6284
|
+
return self._json(400, {"error": "a share names a kind (profile or account) and an id"})
|
|
6285
|
+
STORE.set_shares(target[0], target[1], payload.get("workspaces") or [])
|
|
6286
|
+
return self._json(200, {"shares": STORE.shares()})
|
|
5407
6287
|
if len(parts) == 4 and parts[:3] == ["", "api", "prompts"] and PROMPTS is not None:
|
|
5408
6288
|
return self._prompts_put(parts[3])
|
|
5409
6289
|
if parts == ["", "api", "google"] and GOOGLE is not None:
|
|
@@ -5465,6 +6345,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5465
6345
|
return self._json(500, {"error": f"the relay failed: {type(e).__name__}"})
|
|
5466
6346
|
|
|
5467
6347
|
def _post(self):
|
|
6348
|
+
self._scope()
|
|
5468
6349
|
self._alias()
|
|
5469
6350
|
path = self.path.split("?", 1)[0]
|
|
5470
6351
|
if path == "/api/state":
|
|
@@ -5477,8 +6358,22 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5477
6358
|
return self._datasets_post(path)
|
|
5478
6359
|
if path == "/api/packs" or path.startswith("/api/packs/"):
|
|
5479
6360
|
return self._packs_post(path)
|
|
6361
|
+
if path == "/api/workspaces" or path.startswith("/api/workspaces/"):
|
|
6362
|
+
return self._workspaces_post(path)
|
|
5480
6363
|
if path == "/api/plugins" or path.startswith("/api/plugins/"):
|
|
5481
6364
|
return self._plugins_post(path)
|
|
6365
|
+
if path == "/api/connections/share":
|
|
6366
|
+
# The Run bar's Share link, and its Undo (docs/workspaces.md): the
|
|
6367
|
+
# current workspace into (or, undo, out of) a connection's share
|
|
6368
|
+
# set, at once. The reply is the whole map, as the menu's PUT is.
|
|
6369
|
+
if STORE is None:
|
|
6370
|
+
return self._send(404, b"not found", "text/plain")
|
|
6371
|
+
payload = self._payload() or {}
|
|
6372
|
+
target = self._share_target(payload)
|
|
6373
|
+
if target is None:
|
|
6374
|
+
return self._json(400, {"error": "a share names a kind (profile or account) and an id"})
|
|
6375
|
+
STORE.share_with(target[0], target[1], self.ws, payload.get("undo") is not True)
|
|
6376
|
+
return self._json(200, {"shares": STORE.shares()})
|
|
5482
6377
|
if path == "/api/connections/google/start":
|
|
5483
6378
|
return self._google_start()
|
|
5484
6379
|
if path == "/api/prompts" or path.startswith("/api/prompts/"):
|
|
@@ -5500,6 +6395,51 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5500
6395
|
return self._models_ask(payload)
|
|
5501
6396
|
return self._send(404, b"not found", "text/plain")
|
|
5502
6397
|
|
|
6398
|
+
def _workspaces_post(self, path):
|
|
6399
|
+
"""Create, archive, unarchive, Regenerate, set the default and restore,
|
|
6400
|
+
as the registry API (docs/other-tabs.md). Each mutation answers with the affected
|
|
6401
|
+
workspace and the whole list, so the Manage table and its counts
|
|
6402
|
+
redraw from one response; delete is a DELETE and answers with a trash
|
|
6403
|
+
token for Undo."""
|
|
6404
|
+
if WORKSPACES is None:
|
|
6405
|
+
return self._send(404, b"not found", "text/plain")
|
|
6406
|
+
parts = path.split("/")
|
|
6407
|
+
said = lambda wid: self._json(200, {"workspace": WORKSPACES.get(wid), "workspaces": WORKSPACES.list()})
|
|
6408
|
+
if len(parts) == 3:
|
|
6409
|
+
payload = self._payload() or {}
|
|
6410
|
+
# The New-workspace dialog's Connections picker (docs/workspaces.md):
|
|
6411
|
+
# the connections the new workspace may use, each written a concrete
|
|
6412
|
+
# share row; with none named, the SHARE_NEW set is materialised.
|
|
6413
|
+
shares = payload.get("connections") if isinstance(payload.get("connections"), list) else None
|
|
6414
|
+
wid, err = WORKSPACES.create(payload.get("name"), payload.get("slug"), shares)
|
|
6415
|
+
if err:
|
|
6416
|
+
return self._json(err[0], {"error": err[1]})
|
|
6417
|
+
# Starting content (docs/workspaces.md): the demo pack lands in the
|
|
6418
|
+
# new workspace, not the one the request is in, so bind the thread
|
|
6419
|
+
# to it for the install and bind it back after.
|
|
6420
|
+
if payload.get("content") == "demo" and PACKS is not None:
|
|
6421
|
+
set_workspace(wid)
|
|
6422
|
+
try:
|
|
6423
|
+
PACKS.install(demo_pack())
|
|
6424
|
+
finally:
|
|
6425
|
+
set_workspace(self.ws)
|
|
6426
|
+
return said(wid)
|
|
6427
|
+
if len(parts) == 6 and parts[3] == "trash" and parts[5] == "restore":
|
|
6428
|
+
self._payload()
|
|
6429
|
+
wid, err = WORKSPACES.restore(parts[4])
|
|
6430
|
+
if err:
|
|
6431
|
+
return self._json(err[0], {"error": err[1]})
|
|
6432
|
+
return said(wid)
|
|
6433
|
+
if len(parts) == 5 and parts[4] in ("archive", "unarchive", "regenerate", "default"):
|
|
6434
|
+
self._payload()
|
|
6435
|
+
act = {"archive": WORKSPACES.archive, "unarchive": WORKSPACES.unarchive,
|
|
6436
|
+
"regenerate": WORKSPACES.regenerate, "default": WORKSPACES.set_default}[parts[4]]
|
|
6437
|
+
wid, err = act(parts[3])
|
|
6438
|
+
if err:
|
|
6439
|
+
return self._json(err[0], {"error": err[1]})
|
|
6440
|
+
return said(wid)
|
|
6441
|
+
return self._send(404, b"not found", "text/plain")
|
|
6442
|
+
|
|
5503
6443
|
def _packs_post(self, path):
|
|
5504
6444
|
if PACKS is None:
|
|
5505
6445
|
return self._send(404, b"not found", "text/plain")
|
|
@@ -5561,7 +6501,44 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5561
6501
|
return self._json(err[0], {"error": err[1]})
|
|
5562
6502
|
return self._json(201 if got["installed"] else 200, got)
|
|
5563
6503
|
|
|
6504
|
+
def _share_target(self, payload):
|
|
6505
|
+
"""(kind, id) when `payload` names a shareable connection (kind profile
|
|
6506
|
+
or account, a non-empty id), else None -- the caller answers 400. The
|
|
6507
|
+
two kinds are the ones that carry a Workspaces field (docs/workspaces.md)."""
|
|
6508
|
+
kind, cid = payload.get("kind"), payload.get("id")
|
|
6509
|
+
return (kind, cid) if kind in ("profile", "account") and isinstance(cid, str) and cid else None
|
|
6510
|
+
|
|
6511
|
+
def _unshared(self, payload):
|
|
6512
|
+
"""A saved Target profile a relayed request names by its held key, not
|
|
6513
|
+
shared with this request's workspace (docs/workspaces.md): (id, name),
|
|
6514
|
+
else None. Only a held key (KEY_HELD<id>) names a stored profile; a key
|
|
6515
|
+
typed into the Setup form is a connection not yet saved, so nothing
|
|
6516
|
+
gates it. This is the relay/Test seam, beside worker_destinations' run
|
|
6517
|
+
seam -- the two places a connection resolves to a key, so the two
|
|
6518
|
+
places sharing is enforced. The caller writes the 403: a helper that
|
|
6519
|
+
answers here would send the body and still fall through to the proxy."""
|
|
6520
|
+
if STORE is None:
|
|
6521
|
+
return None
|
|
6522
|
+
raw = str((payload or {}).get("key") or "")
|
|
6523
|
+
if not held(raw):
|
|
6524
|
+
return None
|
|
6525
|
+
pid = raw[len(KEY_HELD):]
|
|
6526
|
+
if STORE.shared("profile", pid, self.ws):
|
|
6527
|
+
return None
|
|
6528
|
+
return pid, STORE.profile_name(pid)
|
|
6529
|
+
|
|
6530
|
+
def _refuse_unshared(self, payload):
|
|
6531
|
+
"""The 403 a relay seam answers when `payload` names an unshared
|
|
6532
|
+
profile, else None (nothing written)."""
|
|
6533
|
+
u = self._unshared(payload)
|
|
6534
|
+
if u is None:
|
|
6535
|
+
return False
|
|
6536
|
+
return self._json(403, {"error": not_shared_sentence([u[1]], STORE.workspace_name(self.ws)),
|
|
6537
|
+
"unshared": [{"kind": "profile", "id": u[0], "name": u[1]}]}) or True
|
|
6538
|
+
|
|
5564
6539
|
def _models_list(self, payload):
|
|
6540
|
+
if self._refuse_unshared(payload):
|
|
6541
|
+
return
|
|
5565
6542
|
base = api_base(str(payload.get("url") or "")) or api_base(OLLAMA)
|
|
5566
6543
|
key = relay_key(payload)
|
|
5567
6544
|
ctype = str(payload.get("type") or "")
|
|
@@ -5613,6 +6590,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5613
6590
|
# (evals-core.ts's connectionRequest); the server decides where it goes
|
|
5614
6591
|
# and how the key travels, per the type -- the same mirror the models
|
|
5615
6592
|
# list uses, so a client cannot point the relay at a path of its own.
|
|
6593
|
+
if self._refuse_unshared(payload):
|
|
6594
|
+
return
|
|
5616
6595
|
base = api_base(str(payload.get("url") or "")) or api_base(OLLAMA)
|
|
5617
6596
|
key = relay_key(payload)
|
|
5618
6597
|
if not header_safe(key):
|
|
@@ -5647,7 +6626,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5647
6626
|
if len(json.dumps(d.get("body"))) > MAX_DOC:
|
|
5648
6627
|
return self._json(413, {"error": f"{name} is too large"})
|
|
5649
6628
|
versions, stale = STORE.write({n: {"version": d["version"], "body": d.get("body")}
|
|
5650
|
-
for n, d in docs.items()})
|
|
6629
|
+
for n, d in docs.items()}, self.ws)
|
|
5651
6630
|
if stale is not None:
|
|
5652
6631
|
return self._json(409, {"stale": stale})
|
|
5653
6632
|
# A pin names a group's version, so the group keeps that version from
|
|
@@ -5686,6 +6665,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5686
6665
|
if revs[name] is None:
|
|
5687
6666
|
return self._json(400, {"error": f"{name!r} is not in that Source"})
|
|
5688
6667
|
content["revs"] = revs
|
|
6668
|
+
# A profile not shared with this workspace blocks the run with the
|
|
6669
|
+
# sentence the Run bar shows and the connections it names, so its Share
|
|
6670
|
+
# link can grant them at once (docs/workspaces.md).
|
|
6671
|
+
ws = workspace_of(STORE) if STORE is not None else None
|
|
6672
|
+
bad = unshared_profiles(run, ws)
|
|
6673
|
+
if bad:
|
|
6674
|
+
return self._json(403, {"error": not_shared_sentence([b["name"] for b in bad], STORE.workspace_name(ws)),
|
|
6675
|
+
"unshared": [{"kind": "profile", **b} for b in bad]})
|
|
5689
6676
|
_, why = worker_destinations(run)
|
|
5690
6677
|
if why:
|
|
5691
6678
|
return self._json(403, {"error": why})
|
|
@@ -5887,6 +6874,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
5887
6874
|
parts = path.split("/")
|
|
5888
6875
|
rid = parts[3]
|
|
5889
6876
|
action = parts[4] if len(parts) > 4 else ""
|
|
6877
|
+
# A run is reachable only from its own workspace: an id from another is
|
|
6878
|
+
# a run this workspace does not have (docs/workspaces.md).
|
|
6879
|
+
if QUEUE.get(rid, self.ws) is None:
|
|
6880
|
+
return self._send(404, b"not found", "text/plain")
|
|
5890
6881
|
if action == "cancel":
|
|
5891
6882
|
run, err = QUEUE.cancel(rid)
|
|
5892
6883
|
elif action == "resume":
|
|
@@ -6011,9 +7002,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
6011
7002
|
if len(parts) == 3:
|
|
6012
7003
|
payload = self._payload() or {}
|
|
6013
7004
|
kind = payload.get("type", DEFAULT_SOURCE_TYPE)
|
|
6014
|
-
# A
|
|
6015
|
-
# made in a lab without that sign-in
|
|
6016
|
-
|
|
7005
|
+
# A platform made with a sign-in (Power Automate, with Microsoft's)
|
|
7006
|
+
# cannot be made in a lab without that sign-in; an unknown kind or
|
|
7007
|
+
# platform is refused by create() below with its own sentence.
|
|
7008
|
+
sign_in = (source_entry(kind, payload.get("config")) or {}).get("signIn")
|
|
6017
7009
|
if sign_in and not SIGN_INS.get(sign_in, lambda: False)():
|
|
6018
7010
|
return self._json(400, {"error": "this lab has no Microsoft app to sign in with (Setup › Connections)"
|
|
6019
7011
|
if sign_in == "microsoft" else f"this lab has no {sign_in} sign-in"})
|
|
@@ -6163,6 +7155,9 @@ def main():
|
|
|
6163
7155
|
missing.append("no ImageMagick")
|
|
6164
7156
|
runs = "ready" if not missing else "CANNOT RUN: " + ", ".join(missing)
|
|
6165
7157
|
print(f"runs: {runs}", flush=True)
|
|
7158
|
+
if WORKSPACES is not None:
|
|
7159
|
+
# The workspace trash's undo window is per-session, like the others.
|
|
7160
|
+
WORKSPACES.empty_trash()
|
|
6166
7161
|
if DATASETS is not None:
|
|
6167
7162
|
# A new lab starts with none: the Datasets tab offers New and Import.
|
|
6168
7163
|
DATASETS.empty_trash()
|