blobhub-cli 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/PKG-INFO +2 -2
  2. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/README.md +1 -1
  3. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__init__.py +1 -1
  4. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/client.py +6 -2
  5. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/commands.py +14 -2
  6. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/app.py +2 -2
  7. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/codes.py +0 -1
  8. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/blob.py +34 -40
  9. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/execute.py +9 -2
  10. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/scheduler.py +32 -24
  11. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/workflow.py +1 -1
  12. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/ids.py +24 -2
  13. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/limits.py +0 -2
  14. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/base.py +19 -3
  15. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/resolver.py +18 -19
  16. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/scheduler.py +15 -1
  17. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/references.py +41 -28
  18. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/.gitignore +0 -0
  19. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/LICENSE +0 -0
  20. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/pyproject.toml +0 -0
  21. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/__main__.py +0 -0
  22. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/__init__.py +0 -0
  23. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/api/errors.py +0 -0
  24. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/__init__.py +0 -0
  25. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/blob/reference.py +0 -0
  26. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/cli/__init__.py +0 -0
  27. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/__init__.py +0 -0
  28. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/auth.py +0 -0
  29. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/completion.py +0 -0
  30. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/doctor.py +0 -0
  31. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/commands/eject.py +0 -0
  32. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/__init__.py +0 -0
  33. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/allowlist.py +0 -0
  34. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/emit.py +0 -0
  35. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/graph.py +0 -0
  36. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/compiler/sandbox.py +0 -0
  37. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/__init__.py +0 -0
  38. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/credentials.py +0 -0
  39. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/settings.py +0 -0
  40. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/config/sources.py +0 -0
  41. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/console.py +0 -0
  42. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/__init__.py +0 -0
  43. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/drift.py +0 -0
  44. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/eject.py +0 -0
  45. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/hashing.py +0 -0
  46. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/definition/model.py +0 -0
  47. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/exit.py +0 -0
  48. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/__init__.py +0 -0
  49. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/formats/io.py +0 -0
  50. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/__init__.py +0 -0
  51. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/manifest/workflow.py +0 -0
  52. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/__init__.py +0 -0
  53. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/scheduler/schedules.py +0 -0
  54. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/statestore.py +0 -0
  55. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/__init__.py +0 -0
  56. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/definitions.py +0 -0
  57. {blobhub_cli-0.3.0 → blobhub_cli-0.4.0}/src/blobhub_cli/workflow/executions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: blobhub-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: BlobHub command line interface — work with blobs, revisions, and their contents from the terminal
5
5
  Project-URL: Homepage, https://blobhub.io/
6
6
  Project-URL: Documentation, https://docs.blobhub.io/
@@ -151,7 +151,7 @@ my-project/
151
151
  type: workflow_blob_deployment
152
152
  version: "1.0"
153
153
  blob: acme-corp/checkout # <org>/<blob>, or a bare <blob> plus --org/BLOBHUB_ORG
154
- revision: latest # or a pinned revision UUID
154
+ revision: default # the blob's default revision, or a pinned revision UUID
155
155
  definitions:
156
156
  - alias: checkout_flow
157
157
  source: definitions/checkout_flow.json
@@ -98,7 +98,7 @@ my-project/
98
98
  type: workflow_blob_deployment
99
99
  version: "1.0"
100
100
  blob: acme-corp/checkout # <org>/<blob>, or a bare <blob> plus --org/BLOBHUB_ORG
101
- revision: latest # or a pinned revision UUID
101
+ revision: default # the blob's default revision, or a pinned revision UUID
102
102
  definitions:
103
103
  - alias: checkout_flow
104
104
  source: definitions/checkout_flow.json
@@ -5,4 +5,4 @@ Contract: this module is the sole source of the package version — hatchling re
5
5
  Invariant: no other file in this package defines `__version__`.
6
6
  """
7
7
 
8
- __version__ = "0.3.0"
8
+ __version__ = "0.4.0"
@@ -57,8 +57,12 @@ class Client:
57
57
  def __exit__(self, *exc_info: object) -> None:
58
58
  self._http.close()
59
59
 
60
- def get(self, path: str) -> dict:
61
- return self._send("GET", path, gate_status=True)
60
+ def get(self, path: str, params: dict[str, object] | None = None) -> dict:
61
+ # A `None` entry is dropped rather than sent: the paged routes validate their query
62
+ # parameters instead of ignoring them, so `?limit=` or `?cursor=None` is a 400.
63
+ if params is not None:
64
+ params = {key: value for key, value in params.items() if value is not None}
65
+ return self._send("GET", path, gate_status=True, params=params or None)
62
66
 
63
67
  def probe(self, path: str = "/users/me") -> tuple[int, float]:
64
68
  """One unretried request, returning (status_code, elapsed_ms) without gating the body.
@@ -26,8 +26,20 @@ class Api:
26
26
  def get_revision(self, revision_id: str) -> dict:
27
27
  return self._client.get(f"/revisions/{revision_id}")["revision"]
28
28
 
29
- def list_revisions(self, org: str, blob: str) -> list[dict]:
30
- return self._client.get(f"/blobs/{org}/{blob}/revisions")["revisions"]
29
+ def resolve_revision(self, org: str, blob: str, ref: str) -> dict:
30
+ # `ref` is a revision id or the literal `default`. Unlike `get_revision`, this route resolves
31
+ # the ref *within the blob*, so a revision id belonging to another blob answers the same 404
32
+ # as one that does not exist -- which `/revisions/{id}` cannot do, having no blob in its path.
33
+ return self._client.get(f"/blobs/{org}/{blob}/revisions/{ref}")["revision"]
34
+
35
+ def list_revisions(
36
+ self, org: str, blob: str, *, limit: int | None = None, cursor: str | None = None
37
+ ) -> tuple[list[dict], str | None]:
38
+ # One page and the next cursor -- this method deliberately does not loop, for the reason
39
+ # list_schedules gives: a loop here would hide the paging from every test that uses it.
40
+ body = self._client.get(
41
+ f"/blobs/{org}/{blob}/revisions", {"limit": limit, "cursor": cursor})
42
+ return body["revisions"], body["cursor"]
31
43
 
32
44
  def get_blob_limits(self, org: str, blob: str) -> dict:
33
45
  return self._client.get(f"/blobs/{org}/{blob}/limits")["limits"]
@@ -156,7 +156,7 @@ def build_app() -> typer.Typer:
156
156
 
157
157
  @blob_app.command("show")
158
158
  def blob_show(blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org.")) -> None:
159
- """Show a blob's record and the phase of its latest revision."""
159
+ """Show a blob's record and the phase of its default revision."""
160
160
  from blobhub_cli.commands import blob as blob_cmd
161
161
 
162
162
  blob_cmd.show(blob)
@@ -165,7 +165,7 @@ def build_app() -> typer.Typer:
165
165
  def blob_revisions(
166
166
  blob: str = typer.Argument(..., help="Blob as '<org>/<name>', or a bare name with --org."),
167
167
  ) -> None:
168
- """List a blob's revisions, newest first, marking the latest."""
168
+ """List a blob's revisions, newest first, marking the default."""
169
169
  from blobhub_cli.commands import blob as blob_cmd
170
170
 
171
171
  blob_cmd.revisions(blob)
@@ -88,7 +88,6 @@ SCHEDULE_NOT_ACCESSIBLE = "SCHEDULE_NOT_ACCESSIBLE"
88
88
 
89
89
  # Advisories (warnings, not exit-worthy failures)
90
90
  DEFINITION_LIST_TRUNCATED = "DEFINITION_LIST_TRUNCATED"
91
- REVISION_LIST_TRUNCATED = "REVISION_LIST_TRUNCATED"
92
91
  ALLOWLIST_UNREADABLE = "ALLOWLIST_UNREADABLE"
93
92
  ALLOWLIST_STALE = "ALLOWLIST_STALE"
94
93
  ALLOWLIST_ENTRY_UNUSABLE = "ALLOWLIST_ENTRY_UNUSABLE"
@@ -11,7 +11,6 @@ from collections.abc import Callable
11
11
  from datetime import UTC, datetime
12
12
 
13
13
  from blobhub_cli import codes, console
14
- from blobhub_cli import limits as limits_module
15
14
  from blobhub_cli.api.client import Client
16
15
  from blobhub_cli.api.commands import Api
17
16
  from blobhub_cli.api.errors import ApiAuthError, ApiError
@@ -49,9 +48,9 @@ def _render_show(payload: dict) -> None:
49
48
  ("Visibility", "visibility"), ("Status", "status"),
50
49
  ):
51
50
  print(f"{label + ':':<12} {record.get(key, '')}")
52
- revision_id = record.get("latest_revision_id")
53
- revision = payload["latest_revision"]
54
- print(f"{'Latest:':<12} {revision_id or '(none)'}")
51
+ revision_id = record.get("default_revision_id")
52
+ revision = payload["default_revision"]
53
+ print(f"{'Default:':<12} {revision_id or '(none)'}")
55
54
  if revision is None:
56
55
  # Two distinct states, and telling them apart is the difference between "mint a better key"
57
56
  # and "this blob has no revision yet".
@@ -68,7 +67,7 @@ def show(ref_text: str, *, api_factory: Callable[[Settings], Api] = _default_api
68
67
  record = _get_blob(api, ref)
69
68
 
70
69
  revision = None
71
- revision_id = record.get("latest_revision_id")
70
+ revision_id = record.get("default_revision_id")
72
71
  if revision_id:
73
72
  try:
74
73
  revision = api.get_revision(revision_id)
@@ -79,7 +78,7 @@ def show(ref_text: str, *, api_factory: Callable[[Settings], Api] = _default_api
79
78
 
80
79
  # `blob` is the "org/name" label on every command in this group, never the record itself: one
81
80
  # key name across three sibling commands cannot carry two types and stay a machine contract.
82
- payload = {"blob": ref.label(), "record": record, "latest_revision": revision}
81
+ payload = {"blob": ref.label(), "record": record, "default_revision": revision}
83
82
  console.result(payload, _render_show)
84
83
  return payload
85
84
 
@@ -89,7 +88,7 @@ def _render_revisions(payload: dict) -> None:
89
88
  return
90
89
  print(f"{'':2}{'REVISION':<38} {'PHASE':<9} {'STATUS':<11} {'CREATED':<26} COMMENT")
91
90
  for item in payload["revisions"]:
92
- marker = "* " if item["latest"] else " "
91
+ marker = "* " if item["default"] else " "
93
92
  print(f"{marker}{item['id']:<38} {item['phase']:<9} {item['status']:<11} "
94
93
  f"{item['created_at']:<26} {item['comment']}")
95
94
 
@@ -98,41 +97,36 @@ def revisions(ref_text: str, *, api_factory: Callable[[Settings], Api] = _defaul
98
97
  ref, resolved = _resolve_ref(ref_text, "blob revisions")
99
98
  api = api_factory(resolved)
100
99
 
101
- # The listing does not report which revision is latest, so the blob record is required, not
100
+ # The listing does not report which revision is the default, so the blob record is required, not
102
101
  # merely enrichment.
103
102
  record = _get_blob(api, ref)
104
- latest_revision_id = record.get("latest_revision_id")
105
- found = api.list_revisions(ref.org, ref.name)
106
-
107
- maximum = limits_module.maximum(
108
- limits_module.read_optional(api, ref.org, ref.name), limits_module.LIMIT_REVISIONS_PER_BLOB
109
- )
110
- if maximum is not None and len(found) >= maximum:
111
- console.advisory(
112
- codes.REVISION_LIST_TRUNCATED,
113
- f"the revision listing returned {len(found)} revision(s), matching the blob's configured "
114
- f"maximum of {maximum} -- the platform cannot page this listing, so the result may be "
115
- "missing revisions it silently dropped",
116
- )
117
-
118
- # Newest first: DynamoDB returns index order, which is neither documented nor useful, and the
119
- # reason to run this command is almost always to find the newest revision.
120
- items = sorted(
121
- (
122
- {
123
- "id": item["id"],
124
- "phase": item.get("phase", ""),
125
- "status": item.get("status", ""),
126
- "created_at": item.get("created_at", ""),
127
- "comment": item.get("comment", ""),
128
- "parent_id": item.get("parent_id"),
129
- "latest": item["id"] == latest_revision_id,
130
- }
131
- for item in found
132
- ),
133
- key=lambda item: item["created_at"],
134
- reverse=True,
135
- )
103
+ default_revision_id = record.get("default_revision_id")
104
+
105
+ # Drained page by page, terminating on the cursor and never on a short page -- the same rule
106
+ # `scheduler/schedules.py`'s `load_all` follows, and for the same reason: the cursor is absent
107
+ # only once the query is exhausted, so a final page can legitimately be exactly full.
108
+ found: list[dict] = []
109
+ cursor: str | None = None
110
+ while True:
111
+ page, cursor = api.list_revisions(ref.org, ref.name, cursor=cursor)
112
+ found.extend(page)
113
+ if cursor is None:
114
+ break
115
+
116
+ # Order comes from the route, which queries the `blob_id-created_at` index newest-first and
117
+ # documents that. Re-sorting a complete, ordered result could only reorder ties.
118
+ items = [
119
+ {
120
+ "id": item["id"],
121
+ "phase": item.get("phase", ""),
122
+ "status": item.get("status", ""),
123
+ "created_at": item.get("created_at", ""),
124
+ "comment": item.get("comment", ""),
125
+ "parent_id": item.get("parent_id"),
126
+ "default": item["id"] == default_revision_id,
127
+ }
128
+ for item in found
129
+ ]
136
130
 
137
131
  payload = {"blob": ref.label(), "revisions": items}
138
132
  console.result(payload, _render_revisions)
@@ -30,7 +30,8 @@ from blobhub_cli.workflow import definitions, executions
30
30
 
31
31
  # blobhub-web serves the routes `Navigation.workflowPath` builds (src/context/nav.js) from
32
32
  # blobhub.io; the path itself -- `/{org_id}/{blob_id}/debugger/{session_id}/{execution_id}` -- is
33
- # reproduced below rather than assumed (spec §1.4).
33
+ # reproduced below rather than assumed (spec §1.4). The revision rides as `?revision=`, never in the
34
+ # path: that path form is deleted, and the slot it used is the debugger's own.
34
35
  DEBUGGER_BASE_URL = "https://blobhub.io"
35
36
 
36
37
 
@@ -85,7 +86,13 @@ def execute(
85
86
  definition_id = set_.remote[(entry.category, entry.alias)].id
86
87
  execution = api.create_execution(target.revision_id, session_id, definition_id=definition_id)
87
88
 
88
- url = f"{DEBUGGER_BASE_URL}/{target.org_id}/{target.blob_id}/debugger/{session_id}/{execution['id']}"
89
+ # `?revision=` carries the RESOLVED id, never the manifest's symbol: an execution lives in one
90
+ # revision, and a link without it opens on whatever revision the reader last chose, where this
91
+ # session does not exist. blobhub-web drops the parameter itself once it names the default.
92
+ url = (
93
+ f"{DEBUGGER_BASE_URL}/{target.org_id}/{target.blob_id}/debugger/{session_id}/{execution['id']}"
94
+ f"?revision={target.revision_id}"
95
+ )
89
96
  # Launching a browser is a side effect, not output -- there is no `console.stream()` call this
90
97
  # could route through -- so it is the one place this command asks `console.json_mode()` directly.
91
98
  if open_browser and not console.json_mode():
@@ -58,7 +58,7 @@ from blobhub_cli.config import settings
58
58
  from blobhub_cli.config.settings import Settings
59
59
  from blobhub_cli.exit import CliExit
60
60
  from blobhub_cli.formats import io
61
- from blobhub_cli.manifest import resolver, scheduler
61
+ from blobhub_cli.manifest import base, resolver, scheduler
62
62
  from blobhub_cli.scheduler import references, schedules
63
63
 
64
64
  # Spec §8: the real workflow_executions_per_session maximum needs an admin key most callers don't
@@ -79,7 +79,7 @@ def _render_ls(payload: dict) -> None:
79
79
  print(f"{'':2}{'ALIAS':<30} {'ID':<38} {'REPEAT':<15} {'ENABLED':<8} {'STATE':<10} WORKFLOW")
80
80
  for item in payload["schedules"]:
81
81
  # `*` marks a schedule the manifest covers -- the same marker `workflow ls` and
82
- # `_render_revisions` (`commands/blob.py`) use for coverage/latest, so this table reads like
82
+ # `_render_revisions` (`commands/blob.py`) use for coverage/default, so this table reads like
83
83
  # its siblings at a glance.
84
84
  marker = "* " if item["in_manifest"] else " "
85
85
  # A schedule the platform stored with no alias has nothing to put in this column; the ID
@@ -136,15 +136,12 @@ def ls(
136
136
  return payload
137
137
 
138
138
 
139
- def _reversed_entry(api: Api, record: dict, *, manifest_org_id: str, original_revision: str) -> dict:
139
+ def _reversed_entry(api: Api, record: dict, *, manifest_org_id: str) -> dict:
140
140
  # Built as a plain dict, not a `manifest.scheduler.ScheduleEntry` -- `pull` writes a document
141
141
  # through `formats.io`, and the dataclass exists to be parsed *from* a manifest, not serialized
142
142
  # back into one. Field order here matches the manifest sample in the spec, so a pulled file reads
143
143
  # the same way a hand-authored one does.
144
- target = references.reverse_target(
145
- api, record["invocation_target"], manifest_org_id=manifest_org_id,
146
- original_revision=original_revision,
147
- )
144
+ target = references.reverse_target(api, record["invocation_target"], manifest_org_id=manifest_org_id)
148
145
  target_doc = {
149
146
  "type": target.type,
150
147
  "blob": target.blob,
@@ -206,21 +203,11 @@ def pull(
206
203
  # firing, and dropping it silently is how an operator ends up with a schedule they cannot see.
207
204
  unmanageable = [record["id"] for record in records if record.get("alias") is None]
208
205
 
209
- # Reversal must reproduce the symbol the entry actually wrote, and a stored `invocation_target`
210
- # records only the resolved id -- so the manifest's own entries are the sole surviving evidence
211
- # of whether a target revision was written as `latest` or deliberately pinned. Matched by alias,
212
- # the same identity `deploy` decides create-versus-update on; a schedule with no manifest entry
213
- # has no original symbol to reproduce, and `latest` stays the right default for it.
214
- original_revisions = {entry.alias: entry.target.revision for entry in manifest.schedules}
215
-
216
206
  # Sorted by alias, like `ls`'s rows: deterministic output a diff or a script can rely on, not an
217
207
  # accident of `list_schedules`' newest-first paging order.
218
208
  entries = sorted(
219
209
  (
220
- _reversed_entry(
221
- api, record, manifest_org_id=target.org_id,
222
- original_revision=original_revisions.get(record["alias"], "latest"),
223
- )
210
+ _reversed_entry(api, record, manifest_org_id=target.org_id)
224
211
  for record in records if record.get("alias") is not None
225
212
  ),
226
213
  key=lambda entry: entry["alias"],
@@ -276,8 +263,12 @@ def _local_fields(
276
263
  "enabled": entry.enabled,
277
264
  "start_date": entry.start_date,
278
265
  "end_date": entry.end_date,
279
- "target.revision": target.revision_id,
280
- "target.session": target.session_id,
266
+ # The symbols the platform stores, not the ids they resolved to. `target.blob` is not
267
+ # cosmetic: under `revision: default` the revision symbol is identical for every blob, so
268
+ # without it a manifest retargeted at a different blob compares equal and is never written.
269
+ "target.blob": target.blob_id,
270
+ "target.revision": target.revision_ref,
271
+ "target.session": _stored_session(target),
281
272
  "target.workflow": target.workflow_alias,
282
273
  "target.input_data": target.input_data,
283
274
  "target.description": target.description,
@@ -298,8 +289,9 @@ def _remote_fields(record: dict) -> dict:
298
289
  "enabled": record["enabled"],
299
290
  "start_date": record.get("start_date"),
300
291
  "end_date": record.get("end_date"),
301
- "target.revision": invocation_target["revision_id"],
302
- "target.session": invocation_target["session_id"],
292
+ "target.blob": invocation_target["blob_id"],
293
+ "target.revision": invocation_target["revision"],
294
+ "target.session": invocation_target["session"],
303
295
  "target.workflow": invocation_target["workflow_alias"],
304
296
  "target.input_data": invocation_target.get("input_data", []),
305
297
  "target.description": invocation_target.get("description"),
@@ -373,9 +365,13 @@ def diff(
373
365
 
374
366
 
375
367
  def _invocation_target(resolved: references.ResolvedTarget) -> dict:
368
+ # INVOCATION_TARGET_SCHEMA is additionalProperties: false and has no `org_id` -- the platform
369
+ # derives the org from the blob. `revision` is the manifest's own symbol, not the id it resolved
370
+ # to, because storing the id is exactly the pinning a following target exists to avoid.
376
371
  target: dict[str, object] = {
377
- "revision_id": resolved.revision_id,
378
- "session_id": resolved.session_id,
372
+ "blob_id": resolved.blob_id,
373
+ "revision": resolved.revision_ref,
374
+ "session": _stored_session(resolved),
379
375
  "workflow_alias": resolved.workflow_alias,
380
376
  "input_data": resolved.input_data,
381
377
  }
@@ -384,6 +380,18 @@ def _invocation_target(resolved: references.ResolvedTarget) -> dict:
384
380
  return target
385
381
 
386
382
 
383
+ def _stored_session(target: references.PreviewedTarget | references.ResolvedTarget) -> str | None:
384
+ """What the platform ends up holding in `invocation_target.session` for this target.
385
+
386
+ A following target keeps its alias -- `_resolve_invocation_target_blob` writes the resolved id
387
+ back only for a pinned one. Sending what the platform will store is what keeps a second `deploy`
388
+ of an unchanged manifest a no-op rather than a perpetual update.
389
+ """
390
+ if target.revision_ref == base.REVISION_DEFAULT:
391
+ return target.session_ref
392
+ return target.session_id
393
+
394
+
387
395
  def _schedule_fields(entry: scheduler.ScheduleEntry, resolved: references.ResolvedTarget) -> dict:
388
396
  # Keys omitted rather than sent as null: every command schema is additionalProperties: false
389
397
  # (api/commands.py), so an explicit start_date/end_date: null would be a 400, not an ignored field.
@@ -654,7 +654,7 @@ def _render_ls(payload: dict) -> None:
654
654
  print(f"{'':2}{'CATEGORY':<10} {'ALIAS':<30} ID")
655
655
  for item in payload["definitions"]:
656
656
  # `*` marks a definition the manifest covers -- the same marker `_render_revisions`
657
- # (`commands/blob.py`) uses for "latest", so this table reads like its sibling at a glance.
657
+ # (`commands/blob.py`) uses for "default", so this table reads like its sibling at a glance.
658
658
  marker = "* " if item["in_manifest"] else " "
659
659
  print(f"{marker}{item['category']:<10} {item['alias']:<30} {item['id']}")
660
660
 
@@ -1,4 +1,4 @@
1
- """Identifier-shape checks shared across domains.
1
+ r"""Identifier-shape checks shared across domains.
2
2
 
3
3
  Contract: `looks_like_uuid` is the one syntax check for whether a string is shaped like a UUID,
4
4
  factored out here so no single domain module (blob references, sessions, and schedules) owns a
@@ -6,7 +6,7 @@ check every domain needs.
6
6
  Invariant: this is a shape check, not an existence check -- a well-formed UUID may still resolve to
7
7
  nothing, and callers must still handle a not-found/not-accessible response after this returns True.
8
8
 
9
- There are two predicates here, not one, because they answer different questions. `looks_like_uuid`
9
+ There are three predicates here, not one, because they answer different questions. `looks_like_uuid`
10
10
  is read-side and permissive on purpose: it delegates to `uuid.UUID`, which also accepts a dashless
11
11
  32-hex string, a `{braced}` form, and a `urn:uuid:` form, and every one of the CLI's existing
12
12
  "is this an id or an alias" branches wants exactly that leniency -- a caller handing back any spelling
@@ -19,6 +19,15 @@ ordinary alias as far as the platform is concerned -- `is_canonical_uuid` must n
19
19
  of `looks_like_uuid`, or it would reject aliases the platform accepts, and `looks_like_uuid` must
20
20
  never be used to gate alias creation, or it would reject aliases the platform accepts for a different
21
21
  reason (dashless 32-hex parses as a UUID but is not the canonical shape the platform refuses).
22
+
23
+ `matches_platform_session_id_shape` is the third, and it is neither of the others: it is a literal
24
+ copy of one JSON-Schema pattern, `INVOCATION_TARGET_SCHEMA`'s `SESSION_ID_SHAPE_PATTERN`, which
25
+ decides whether a following schedule target's `session` is refused at save. It separates from
26
+ `looks_like_uuid` in BOTH directions, which is why substituting either for the other is wrong:
27
+ dashless 32-hex is a UUID to `uuid.UUID` and an ordinary alias to the schema, while a
28
+ whitespace-padded UUID is not a UUID to `uuid.UUID` and IS a session id to the schema, whose pattern
29
+ is wrapped in `\s*`. Use it only where the question is "what will that schema do", never as a
30
+ general id check.
22
31
  """
23
32
 
24
33
  import re
@@ -29,6 +38,14 @@ import uuid
29
38
  # docstring for why a dashless 32-hex string must stay outside this pattern.
30
39
  _CANONICAL_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
31
40
 
41
+ # `INVOCATION_TARGET_SCHEMA`'s SESSION_ID_SHAPE_PATTERN, character for character (blobhub-infra
42
+ # `api/blob_types/blobhub/compute/scheduler/component.py:147`). The `\s*` wrapper and the
43
+ # case-insensitive hex are the platform's, not a convenience: `get_session_by_session_id_or_alias`
44
+ # strips and lower-cases before deciding id-from-alias, so a padded or upper-case UUID reaches it as
45
+ # an id. Copied rather than approximated -- an approximation refuses manifests the platform accepts.
46
+ _PLATFORM_SESSION_ID_SHAPE = re.compile(
47
+ r"^\s*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\s*$")
48
+
32
49
 
33
50
  def looks_like_uuid(value: str) -> bool:
34
51
  try:
@@ -40,3 +57,8 @@ def looks_like_uuid(value: str) -> bool:
40
57
 
41
58
  def is_canonical_uuid(value: str) -> bool:
42
59
  return bool(_CANONICAL_UUID.match(value))
60
+
61
+
62
+ def matches_platform_session_id_shape(value: str) -> bool:
63
+ """True when `INVOCATION_TARGET_SCHEMA` would read `value` as a session id rather than an alias."""
64
+ return bool(_PLATFORM_SESSION_ID_SHAPE.match(value))
@@ -10,8 +10,6 @@ answer confidently and wrongly.
10
10
  from blobhub_cli.api.commands import Api
11
11
  from blobhub_cli.api.errors import ApiError
12
12
 
13
- LIMIT_REVISIONS_PER_BLOB = "revisions_per_blob"
14
-
15
13
 
16
14
  def read_optional(api: Api, org: str | None, blob: str | None) -> dict | None:
17
15
  # Limits require an admin key (F1 spec §1.6); a read/write key gets a 403 here. There is no
@@ -16,6 +16,13 @@ from blobhub_cli import codes
16
16
  from blobhub_cli.blob import reference
17
17
  from blobhub_cli.exit import CliExit
18
18
 
19
+ # The one revision reference that is a resolution rule rather than an id: it names whatever the
20
+ # blob's `default_revision_id` points at right now (blobhub-infra `RevisionsTable.REF_DEFAULT`).
21
+ REVISION_DEFAULT = "default"
22
+
23
+ # What `REVISION_DEFAULT` replaced. Kept only so the check below can name the rename.
24
+ _REVISION_RENAMED_FROM = "latest"
25
+
19
26
 
20
27
  @dataclass(frozen=True)
21
28
  class ManifestBase:
@@ -59,18 +66,27 @@ def check_blob_reference(blob: str, hint: str, *, label: str = "blob") -> None:
59
66
 
60
67
 
61
68
  def check_revision(revision: str, hint: str, *, label: str = "revision") -> None:
62
- """A revision reference is `latest` or a platform revision id, which is a bare lowercase UUID
69
+ """A revision reference is `default` or a platform revision id, which is a bare lowercase UUID
63
70
  (`Handler.generate_id`). Shared with `target.revision` for the same reason as above -- answered
64
71
  offline, where the message can name the malformed value, rather than as an unreachable revision
65
72
  one network round trip later.
66
73
  """
67
- if revision == "latest":
74
+ if revision == REVISION_DEFAULT:
68
75
  return
76
+ if revision == _REVISION_RENAMED_FROM:
77
+ # A well-formed string, so the generic message below would send its author looking for a
78
+ # typo instead of a rename -- and the platform reads it as a revision id and answers 404.
79
+ raise CliExit(
80
+ codes.MANIFEST_INVALID,
81
+ f"{label} {revision!r} is no longer a revision reference; a blob's moving pointer is now "
82
+ f"named {REVISION_DEFAULT!r}",
83
+ hint,
84
+ )
69
85
  try:
70
86
  uuid.UUID(revision)
71
87
  except ValueError as exc:
72
88
  raise CliExit(
73
- codes.MANIFEST_INVALID, f"{label} {revision!r} is neither 'latest' nor a UUID", hint,
89
+ codes.MANIFEST_INVALID, f"{label} {revision!r} is neither {REVISION_DEFAULT!r} nor a UUID", hint,
74
90
  ) from exc
75
91
 
76
92
 
@@ -1,8 +1,8 @@
1
1
  """General reference resolver: turns a parsed `ManifestBase` into concrete platform coordinates.
2
2
 
3
3
  Contract: `resolve` checks blob accessibility, the caller-supplied `expect` blob type (before any
4
- data-channel call, which would otherwise answer an opaque `500`), `latest` resolution through
5
- `blob.latest_revision_id`, then the status/phase gate, in that fixed order. `expect` is a required
4
+ data-channel call, which would otherwise answer an opaque `500`), the revision through the
5
+ blob-scoped resolve route, then the status/phase gate, in that fixed order. `expect` is a required
6
6
  keyword-only argument -- this module resolves `blob`/`revision` coordinates for any blob type, and
7
7
  a default of the workflow type would leave that one type's assumption sitting inside a resolver
8
8
  that calls itself general.
@@ -14,7 +14,7 @@ from dataclasses import dataclass
14
14
 
15
15
  from blobhub_cli import codes
16
16
  from blobhub_cli.api.commands import Api
17
- from blobhub_cli.api.errors import ApiAuthError
17
+ from blobhub_cli.api.errors import ApiAuthError, ApiCommandError
18
18
  from blobhub_cli.blob import reference
19
19
  from blobhub_cli.blob.reference import BlobRef
20
20
  from blobhub_cli.exit import CliExit
@@ -34,7 +34,6 @@ class Target:
34
34
  revision_id: str
35
35
  phase: str
36
36
  status: str
37
- latest_revision_id: str
38
37
 
39
38
 
40
39
  def command_group(expect: str) -> str:
@@ -80,19 +79,26 @@ def resolve(
80
79
  f"blob {org}/{blob_name} is {blob_type!r}, not a {expect!r} blob",
81
80
  )
82
81
 
83
- revision_id = base.revision
84
- latest_revision_id = blob["latest_revision_id"]
85
- if revision_id == "latest":
86
- revision_id = latest_revision_id
87
-
82
+ # The manifest's symbol goes to the platform verbatim: `default` is the platform's own
83
+ # resolution rule, and the route resolves it against the blob in the path -- which is also what
84
+ # makes it refuse a pinned revision belonging to some other blob, something `/revisions/{id}`
85
+ # has no blob to check against.
88
86
  try:
89
- revision = api.get_revision(revision_id)
90
- except ApiAuthError as exc:
87
+ revision = api.resolve_revision(org, blob_name, base.revision)
88
+ except (ApiAuthError, ApiCommandError) as exc:
89
+ # Both, not just the first: the route answers 403 for a blob the key cannot read and a
90
+ # `revision_not_found` body at 404 for a revision that is missing or belongs elsewhere, and
91
+ # the client maps those to two different classes.
92
+ # The second clause is not decoration: a well-formed UUID that resolved yesterday now fails
93
+ # because the route checks ownership, and without it the message sends its author hunting
94
+ # for a deleted revision instead of at the `blob:` line above it.
91
95
  raise CliExit(
92
96
  codes.REVISION_NOT_ACCESSIBLE,
93
- f"revision {revision_id} is absent or not reachable",
97
+ f"revision {base.revision} is absent or not reachable on {org}/{blob_name}; a pinned "
98
+ f"revision must belong to the blob named in 'blob:'",
94
99
  f"blobhub {group} diff -f {base.path}",
95
100
  ) from exc
101
+ revision_id = revision["id"]
96
102
 
97
103
  status = revision["status"]
98
104
  phase = revision["phase"]
@@ -111,11 +117,4 @@ def resolve(
111
117
  revision_id=revision_id,
112
118
  phase=phase,
113
119
  status=status,
114
- latest_revision_id=latest_revision_id,
115
120
  )
116
-
117
-
118
- def reverse_revision(base_revision: str, resolved_id: str, latest_revision_id: str) -> str:
119
- if base_revision != "latest":
120
- return base_revision
121
- return "latest" if resolved_id == latest_revision_id else resolved_id
@@ -128,10 +128,24 @@ def _parse_target(raw: object, hint: str) -> TargetRef:
128
128
  revision_ref = _require_str(raw, "revision", hint)
129
129
  base.check_revision(revision_ref, hint, label="target revision")
130
130
 
131
+ # INVOCATION_TARGET_SCHEMA's one cross-field rule, mirrored offline: a following target may not
132
+ # name its session by id, because no session id survives a revision boundary. The predicate is
133
+ # the schema's own pattern rather than `looks_like_uuid`, which differs from it in both
134
+ # directions and would refuse a legal alias as readily as it would miss a real id.
135
+ session_ref = _require_str(raw, "session", hint)
136
+ if revision_ref == base.REVISION_DEFAULT and ids.matches_platform_session_id_shape(session_ref):
137
+ raise CliExit(
138
+ codes.MANIFEST_INVALID,
139
+ f"target session {session_ref!r} is a session id, and a target following "
140
+ f"{base.REVISION_DEFAULT!r} must name its session by alias -- no session id survives the "
141
+ "revision the default moves to",
142
+ hint,
143
+ )
144
+
131
145
  return TargetRef(
132
146
  blob=blob_ref,
133
147
  revision=revision_ref,
134
- session=_require_str(raw, "session", hint),
148
+ session=session_ref,
135
149
  workflow=_require_str(raw, "workflow", hint),
136
150
  input_data=input_data,
137
151
  description=description,
@@ -11,19 +11,19 @@ refuse the target runs while refusing is still free. A failure anywhere in the b
11
11
  workflow-alias trio is re-raised as one `SCHEDULE_TARGET_INVALID` naming the target's own blob or
12
12
  alias, matching the code's documented scope; a session failure is not folded in and keeps
13
13
  `resolve_session`'s own codes, since `SCHEDULE_TARGET_INVALID` names only blob/revision/workflow.
14
- `reverse_target` undoes it: a `revision` the entry wrote as `latest` reverses to `"latest"` only
15
- while it still leads the blob and to the literal id otherwise, while one the entry *pinned* reverses
16
- to that pin -- which is why the original symbol is a caller-supplied argument rather than assumed
17
- (`resolver.reverse_revision`, reused unchanged), a session id
18
- reverses to its alias when it has one, and `blob` reverses to a bare name when the target lives in
19
- the *manifest's own* org -- reproducing the original symbol, not a UUID, which is the whole point of
20
- `pull` (spec §12.1, §13.3) -- and to a fully-qualified `org_id/blob_alias` reference otherwise, since
21
- a blob record carries no human-readable org handle to qualify it with instead. The caller supplies
14
+ `reverse_target` undoes it, and now mostly by *reading* rather than deriving: a stored target records
15
+ `blob_id`, the `revision` symbol itself (`default` or a pinned id) and, for a following target, the
16
+ session alias -- so `revision` reverses verbatim and a following target needs no session lookup at
17
+ all. Only a pinned target still resolves its session id back to an alias, since that is the one shape
18
+ the platform rewrites. `blob` reverses to a bare name when the target lives in the *manifest's own*
19
+ org -- reproducing the original symbol, not a UUID, which is the whole point of `pull` (spec §12.1,
20
+ §13.3) -- and to a fully-qualified `org_id/blob_alias` reference otherwise, since a blob record
21
+ carries no human-readable org handle to qualify it with instead. The caller supplies
22
22
  `manifest_org_id` because it already has it: `pull` must resolve the manifest's own scheduler blob
23
- before it can list schedules at all, and that resolution already returns `Target.org_id`.
24
- Invariant: the target blob/revision is read-only here (`require_writable=False`) -- deploying a
25
- schedule creates a session on the target revision but never writes to the revision's own content,
26
- so scheduling against a `commit` or `managed` phase target is fine.
23
+ before it can list schedules at all, and that resolution already returns `Target.org_id`. Why the
24
+ symbol is read rather than derived from the blob's pointer: docs/specs/2026-09-21-revision-default.md
25
+ Invariant: a target revision must admit writes (`{draft, managed}`), because the schedule's whole
26
+ purpose is to write an execution into it and the platform checks that phase at save time.
27
27
 
28
28
  `ResolvedTarget.blob_id` (Task 8) exists only so `deploy`'s saturation check can read the target
29
29
  blob's limits without a second resolution: `get_blob`/`get_blob_limits` both resolve `blob` by
@@ -53,7 +53,7 @@ from blobhub_cli import codes, console, ids, limits
53
53
  from blobhub_cli.api.commands import Api
54
54
  from blobhub_cli.api.errors import ApiAuthError
55
55
  from blobhub_cli.exit import CliExit
56
- from blobhub_cli.manifest import resolver
56
+ from blobhub_cli.manifest import base, resolver
57
57
  from blobhub_cli.manifest.base import ManifestBase
58
58
  from blobhub_cli.manifest.resolver import Target
59
59
  from blobhub_cli.manifest.scheduler import TargetRef
@@ -66,6 +66,9 @@ _WORKFLOW_DEFINITIONS_LIMIT = "workflow_definitions_per_revision"
66
66
  @dataclass(frozen=True)
67
67
  class ResolvedTarget:
68
68
  revision_id: str
69
+ revision_ref: str # the manifest's own symbol -- `default` or a pinned id -- which is what the
70
+ # platform stores; `revision_id` is what it resolved to here and is what every other call needs.
71
+ session_ref: str # likewise the manifest's own symbol, which is what a FOLLOWING target stores
69
72
  session_id: str
70
73
  session_created: bool
71
74
  workflow_alias: str
@@ -88,8 +91,12 @@ def _target_base(target: TargetRef) -> ManifestBase:
88
91
 
89
92
  def _resolve_blob_revision(api: Api, target: TargetRef, *, manifest_org: str) -> Target:
90
93
  try:
94
+ # `require_writable=True`, because firing this schedule writes an execution into the target
95
+ # revision and `create_schedule` refuses a phase outside PHASES_WRITE
96
+ # (blobhub-infra scheduler `component.py:623`). Refusing here is what keeps that 403 out of
97
+ # `deploy`'s pass 2, where earlier entries are already written and already firing.
91
98
  return resolver.resolve(
92
- api, _target_base(target), require_writable=False, expect=resolver.WORKFLOW_BLOB,
99
+ api, _target_base(target), require_writable=True, expect=resolver.WORKFLOW_BLOB,
93
100
  default_org=manifest_org,
94
101
  )
95
102
  except CliExit as exc:
@@ -167,6 +174,8 @@ def resolve_target(api: Api, target: TargetRef, *, manifest_org: str) -> Resolve
167
174
 
168
175
  return ResolvedTarget(
169
176
  revision_id=resolved.revision_id,
177
+ revision_ref=target.revision,
178
+ session_ref=target.session,
170
179
  session_id=session_id,
171
180
  session_created=session_created,
172
181
  workflow_alias=target.workflow,
@@ -179,11 +188,14 @@ def resolve_target(api: Api, target: TargetRef, *, manifest_org: str) -> Resolve
179
188
  @dataclass(frozen=True)
180
189
  class PreviewedTarget:
181
190
  revision_id: str
191
+ revision_ref: str
192
+ session_ref: str
182
193
  workflow_alias: str
183
194
  input_data: list
184
195
  description: str | None
185
196
  session_id: str | None # None means the session does not exist yet -- diff must not create it
186
197
  session_would_create: bool
198
+ blob_id: str
187
199
 
188
200
 
189
201
  def preview_target(api: Api, target: TargetRef, *, manifest_org: str) -> PreviewedTarget:
@@ -213,19 +225,19 @@ def preview_target(api: Api, target: TargetRef, *, manifest_org: str) -> Preview
213
225
 
214
226
  return PreviewedTarget(
215
227
  revision_id=resolved.revision_id,
228
+ revision_ref=target.revision,
229
+ session_ref=target.session,
216
230
  workflow_alias=target.workflow,
217
231
  input_data=target.input_data,
218
232
  description=target.description,
219
233
  session_id=session_id,
220
234
  session_would_create=session_would_create,
235
+ blob_id=resolved.blob_id,
221
236
  )
222
237
 
223
238
 
224
- def reverse_target(
225
- api: Api, invocation_target: dict, *, manifest_org_id: str, original_revision: str = "latest"
226
- ) -> TargetRef:
227
- revision_id = invocation_target["revision_id"]
228
- blob_id = api.get_revision(revision_id)["blob_id"]
239
+ def reverse_target(api: Api, invocation_target: dict, *, manifest_org_id: str) -> TargetRef:
240
+ blob_id = invocation_target["blob_id"]
229
241
 
230
242
  # `GET /blobs/{org}/{blob}` resolves `{blob}` by primary key, and never consults `{org}` at all,
231
243
  # the moment `{blob}` is a real id rather than an alias (`get_blob_by_org_id_and_blob_id`,
@@ -238,16 +250,17 @@ def reverse_target(
238
250
  # handle, only its raw `org_id`, so a genuinely cross-org target is qualified with that id rather
239
251
  # than an alias -- it was explicitly qualified by whoever authored it in the first place.
240
252
  blob_ref = blob["alias"] if blob["org_id"] == manifest_org_id else f"{blob['org_id']}/{blob['alias']}"
241
- # `original_revision` is the symbol the manifest entry actually wrote, supplied by the caller
242
- # because nothing in a stored `invocation_target` records it: the platform keeps only the
243
- # resolved id. A manifest that pinned a literal revision did so precisely so the next revision
244
- # cut would not retarget the schedule, and reversing that to `latest` would undo the pin
245
- # silently -- indistinguishably so when the pinned id happens to still be the blob's latest.
246
- # `"latest"` is only the right default where there is no entry to have written anything.
247
- revision_ref = resolver.reverse_revision(original_revision, revision_id, blob["latest_revision_id"])
253
+ # Verbatim: the stored target records the symbol the manifest wrote, `default` included.
254
+ revision_ref = invocation_target["revision"]
248
255
 
249
- session = api.get_session(revision_id, invocation_target["session_id"])
250
- session_ref = session.get("alias") or invocation_target["session_id"]
256
+ if revision_ref == base.REVISION_DEFAULT:
257
+ # A following target keeps the alias it was deployed with -- the platform deliberately does
258
+ # not pin it, because no session id survives the revision the default moves to. There is
259
+ # nothing to look up, and no revision id to look it up in.
260
+ session_ref = invocation_target["session"]
261
+ else:
262
+ session = api.get_session(revision_ref, invocation_target["session"])
263
+ session_ref = session.get("alias") or invocation_target["session"]
251
264
 
252
265
  return TargetRef(
253
266
  blob=blob_ref,
File without changes
File without changes
File without changes