Skip to content

Commit 9d0b0a8

Browse files
AcknowlEDG prep for forge service []
This commit bumps the protocol revision for the service to 19 and includes the following changes: - Review names can now contain links. - The file system tree limit system has been refined for AcknowlEDG clients. - The AcknowlEDG service now boots users that attempt to access files they don't have access to. - Bench statuses and a test run's changes files are now read on demand. - The deploy workflow reads the public API URL from the ACKNOWLEDG_API_HOST repository variable instead of a secret.
1 parent 21065e5 commit 9d0b0a8

19 files changed

Lines changed: 1617 additions & 317 deletions

File tree

‎.agents/skills/acknowledg-client/SKILL.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ From `edgacknowledg.client` (`__all__`):
3030
- `ConnectionStatusListener` — `connected` / `attempting_reconnect` /
3131
`disconnected`
3232
- `ReviewClient` (structural **Protocol**): `review_id`, `name`, `timestamp`,
33-
`sections()`, `add_section` / `remove_section`
33+
`sections()`, `add_section` / `remove_section`. `name` is a `RichName`
34+
(segments built with `rich_text` / `rich_link` from `edgacknowledg`; see
35+
PROTOCOL.md "Rich names"); a plain title is `[rich_text(title)]`
3436
- URL/env helpers: `add_endpoint_arguments`, `form_section_http_url`,
3537
`default_*` host/port helpers
3638
- Shared: `BackgroundWorkerPool`, errors

‎.github/workflows/acknowledg-deploy.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# Host, paths, and credentials come from secrets in the `acknowledg`
1717
# environment (see dev_tools/services/acknowledg/README.md). The SSH setup
1818
# writes them into a `Host acknowledg` alias so they never appear on a command
19-
# line.
19+
# line. The public API URL is the ACKNOWLEDG_API_HOST repository variable.
2020

2121
name: AcknowlEDG Deploy
2222

@@ -61,7 +61,7 @@ jobs:
6161
- name: Build client
6262
working-directory: ${{ env.ACKNOWLEDG_DIR }}/client
6363
env:
64-
VITE_EDG_ACKNOWLEDG_API_HOST: ${{ secrets.ACKNOWLEDG_API_HOST }}
64+
VITE_EDG_ACKNOWLEDG_API_HOST: ${{ vars.ACKNOWLEDG_API_HOST }}
6565
run: |
6666
# The client opens `${VITE_EDG_ACKNOWLEDG_API_HOST}/review`; without
6767
# a scheme the browser resolves that relative to the page instead.

‎dev_tools/bin/edg-acknowledg-cli‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ import edgutil
2121
from edgacknowledg import (
2222
IncompatibleProtocolVersion,
2323
is_ui_section_type,
24+
is_valid_rich_name,
2425
review_display_name,
26+
rich_name_text,
27+
rich_text,
2528
section_slug,
2629
utc_now_iso,
2730
)
@@ -41,7 +44,7 @@ from edgacknowledg.client.bench import (
4144
BenchSourceHandler,
4245
resolve_baseline_dir,
4346
)
44-
from edgacknowledg.client.shared import ReviewClientHandler
47+
from edgacknowledg.client.shared import ReviewClientHandler, SandboxContext
4548
from edgacknowledg.client.test import TestEditHandler, TestSourceHandler
4649
from edgutil import eprint
4750

@@ -96,10 +99,13 @@ def _make_bench_session(parameters: Dict[str, Any],
9699
if session is not None:
97100
return session
98101

102+
# The run (which may lie elsewhere) and the bench home are all a review
103+
# may read.
104+
context = SandboxContext(bench_home, root)
99105
session = BenchReviewSession(
100-
bench_home = bench_home,
101-
run_dir = root,
102-
baseline_dir = resolve_baseline_dir(bench_home, tag),
106+
context = context,
107+
run_dir = context.subpath(root),
108+
baseline_dir = resolve_baseline_dir(context.subpath(bench_home), tag),
103109
tag = tag,
104110
threshold_slow = float(
105111
parameters.get('slower_threshold', DEFAULT_THRESHOLD_SLOW)
@@ -131,11 +137,16 @@ def _make_handler(section: Dict[str, Any],
131137
if section_type == 'test.src':
132138
if test_home is None:
133139
raise ValueError('test home required for test.src sections')
140+
root = Path(parameters['root'])
141+
# The run (which may lie elsewhere) and the test home are all a review
142+
# may read.
143+
context = SandboxContext(test_home, root)
134144
return TestSourceHandler(
135145
section_name,
136146
slug,
137-
test_home,
138-
Path(parameters['root'])
147+
context,
148+
context.subpath(test_home),
149+
context.subpath(root)
139150
)
140151
if section_type == 'test.edit':
141152
return TestEditHandler(section_name, slug, worker_pool)
@@ -161,9 +172,17 @@ async def build_review_client(session: BatchClientSession,
161172
if 'id' not in raw:
162173
raise ValueError('review is missing required field "id"')
163174

175+
# A plain title, or a rich name (see PROTOCOL.md).
176+
name = raw.get('name')
177+
if isinstance(name, list):
178+
if not is_valid_rich_name(name):
179+
raise ValueError('review "name" is not a valid rich name')
180+
else:
181+
name = [rich_text(review_display_name(name))]
182+
164183
review = await session.create_review(
165184
uuid.UUID(str(raw['id'])),
166-
review_display_name(raw.get('name')),
185+
name,
167186
str(raw.get('timestamp') or utc_now_iso())
168187
)
169188

@@ -254,7 +273,8 @@ def main() -> None:
254273
session, raw, worker_pool
255274
)
256275
print_connection_status(
257-
f"Serving review \"{review.name}\" ({review.review_id})."
276+
f"Serving review \"{rich_name_text(review.name)}\" "
277+
f"({review.review_id})."
258278
)
259279
print_section_urls(args.ui_hostname, args.ui_port, review)
260280
except (OSError, ValueError) as ex:

‎dev_tools/bin/edg-bench-review‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ from edgacknowledg.client.bench import (
3232
BenchSourceHandler,
3333
resolve_baseline_dir,
3434
)
35+
from edgacknowledg.client.shared import SandboxContext
3536
from edgacknowledg import IncompatibleProtocolVersion
3637
from edgutil import eprint
3738

@@ -166,14 +167,17 @@ def main() -> None:
166167
eprint(f"Path does not exist: {run_dir}")
167168
sys.exit(1)
168169

169-
baseline_dir = resolve_baseline_dir(bench_home, args.tag)
170+
# The run (given with --root, it may lie elsewhere) and the bench home
171+
# are all a review may read.
172+
context = SandboxContext(bench_home, run_dir)
173+
baseline_dir = resolve_baseline_dir(context.subpath(bench_home), args.tag)
170174
if not baseline_dir.exists():
171175
eprint(f"Baseline directory does not exist: {baseline_dir}")
172176
sys.exit(1)
173177

174178
session = BenchReviewSession(
175-
bench_home = bench_home,
176-
run_dir = run_dir,
179+
context = context,
180+
run_dir = context.subpath(run_dir),
177181
baseline_dir = baseline_dir,
178182
tag = args.tag,
179183
threshold_slow = args.threshold_slow,
@@ -206,7 +210,7 @@ def main() -> None:
206210
status_listener = _CliConnectionStatusListener()
207211
) as client_session:
208212
review = await client_session.create_review(
209-
run_id, review_name, created_at
213+
run_id, [edgacknowledg.rich_text(review_name)], created_at
210214
)
211215
await review.add_section(
212216
BenchSourceHandler(

‎dev_tools/bin/edgy-review‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ from edgacknowledg.client import (
2323
add_endpoint_arguments,
2424
form_section_http_url,
2525
)
26+
from edgacknowledg.client.shared import SandboxContext
2627
from edgacknowledg.client.test import TestEditHandler, TestSourceHandler
2728
from edgacknowledg import IncompatibleProtocolVersion
2829
from edgutil import eprint
@@ -161,11 +162,18 @@ def main() -> None:
161162
status_listener = _CliConnectionStatusListener()
162163
) as session:
163164
review = await session.create_review(
164-
run_id, review_name, created_at
165-
)
166-
await review.add_section(
167-
TestSourceHandler(args.section_name, slug, test_home, run_dir)
165+
run_id, [edgacknowledg.rich_text(review_name)], created_at
168166
)
167+
# The run (given with --root, it may lie elsewhere) and the test
168+
# home are all a review may read.
169+
context = SandboxContext(test_home, run_dir)
170+
await review.add_section(TestSourceHandler(
171+
args.section_name,
172+
slug,
173+
context,
174+
context.subpath(test_home),
175+
context.subpath(run_dir)
176+
))
169177
await review.add_section(
170178
TestEditHandler(args.section_name, slug, worker_pool)
171179
)

‎dev_tools/pylibs/edgacknowledg/__init__.py‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
from collections.abc import Mapping
1212
from datetime import datetime, timezone
13-
from typing import Any, Dict, FrozenSet, Optional, Tuple, Union
13+
from typing import Any, Dict, FrozenSet, List, Optional, Tuple, Union
14+
from urllib.parse import urlsplit
1415

1516
# If you change these values, make sure to update the JavaScript client code.
1617
#
@@ -26,6 +27,9 @@ class RequestTags:
2627
INVALID_REQUEST = 'invalid'
2728
OUTDATED_PROTOCOL = 'outdated-proto'
2829
ROLE_OCCUPIED = 'role-occupied'
30+
# A reviewer asked a provider for a file it may not read: provider ->
31+
# server (naming the reviewer), which closes that reviewer's connection.
32+
ILLEGAL_ACCESS = 'illegal-access'
2933
META_SECTION_NAME = 'meta.section.name'
3034
META_REVIEW_NAME = 'meta.review.name'
3135
META_REVIEW_TIMESTAMP = 'meta.review.timestamp'
@@ -117,7 +121,7 @@ def request_tags_with_prefix(prefix: str) -> FrozenSet[str]:
117121
ALLOWED_BENCH_EDIT_TAGS
118122
)
119123

120-
PROTOCOL_REVISION = 18
124+
PROTOCOL_REVISION = 19
121125

122126
# 10MiB
123127
MAX_MESSAGE_SIZE = 10485760
@@ -140,6 +144,72 @@ def review_display_name(name: Optional[str] = None) -> str:
140144
from edgdocker import render_user_name
141145
return f"Review opened by {render_user_name()}"
142146

147+
# A review's name is rich text: segments composed in order, each either
148+
# {'type': 'text', 'text': ...} or {'type': 'link', 'url': ..., 'text': ...}
149+
# (a link's text is optional; its URL is shown without it). See PROTOCOL.md.
150+
RichName = List[Dict[str, str]]
151+
152+
MAX_RICH_NAME_SEGMENTS = 32
153+
# Characters, across every segment's text and URL.
154+
MAX_RICH_NAME_LENGTH = 4096
155+
156+
_RICH_SEGMENT_FIELDS = {
157+
'text': ({'type', 'text'}, {'type', 'text'}),
158+
'link': ({'type', 'url'}, {'type', 'url', 'text'}),
159+
}
160+
161+
def rich_text(text: str) -> Dict[str, str]:
162+
return {'type': 'text', 'text': text}
163+
164+
def rich_link(url: str, text: Optional[str] = None) -> Dict[str, str]:
165+
segment = {'type': 'link', 'url': url}
166+
if text is not None:
167+
segment['text'] = text
168+
return segment
169+
170+
def _is_web_url(url: str) -> bool:
171+
try:
172+
parts = urlsplit(url)
173+
except ValueError:
174+
return False
175+
return parts.scheme in ('http', 'https') and parts.netloc != ''
176+
177+
def is_valid_rich_name(value: Any) -> bool:
178+
'''Return whether ``value`` is a well-formed rich name: a non-empty list
179+
of known segments with only their fields, all non-empty strings, and
180+
links only to http(s) URLs, within the size limits.
181+
'''
182+
if not isinstance(value, list):
183+
return False
184+
if len(value) == 0 or len(value) > MAX_RICH_NAME_SEGMENTS:
185+
return False
186+
length = 0
187+
for segment in value:
188+
if not isinstance(segment, dict):
189+
return False
190+
segment_type = segment.get('type')
191+
if segment_type not in _RICH_SEGMENT_FIELDS:
192+
return False
193+
required, allowed = _RICH_SEGMENT_FIELDS[segment_type]
194+
keys = set(segment)
195+
if not (required <= keys <= allowed):
196+
return False
197+
for key, field in segment.items():
198+
if not isinstance(field, str) or field == '':
199+
return False
200+
if key != 'type':
201+
length += len(field)
202+
if segment['type'] == 'link' and not _is_web_url(segment['url']):
203+
return False
204+
return length <= MAX_RICH_NAME_LENGTH
205+
206+
def rich_name_text(name: RichName) -> str:
207+
'''Return ``name`` as plain text (e.g. for terminal output).'''
208+
return ''.join(
209+
segment['text'] if segment.get('text') else segment.get('url', '')
210+
for segment in name
211+
)
212+
143213
def utc_now_iso() -> str:
144214
'''Return the current UTC time as an ISO-8601 timestamp.'''
145215
return datetime.now(timezone.utc).isoformat()

0 commit comments

Comments
 (0)