Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,16 @@ trademarks or logos is subject to and must follow
[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks/usage/general).
Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
Any use of third-party trademarks or logos are subject to those third-party's policies.

### Optional incremental session discovery

Set `AMPLIFIER_SESSION_CATALOG_HINT_DIRECTORY` to an existing canonical absolute,
same-user private POSIX inbox to publish location-only notices after successful
session saves or renames. The CLI emits no transcript/event contents and does not
start a catalog, agent or history watcher. Repeated updates coalesce atomically;
notice failures do not prevent canonical saves. The independent catalog consumer
must be configured explicitly for the same inbox and native roots. Older installed
CLI versions do not gain this behavior from setting the variable alone.

See [CLI location notices v1](docs/contracts/catalog-location-notices.v1.md) for
bounds, ownership, qualification and the separately unresolved physical-delete seam.
81 changes: 81 additions & 0 deletions amplifier_app_cli/catalog_notices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Opt-in location-only observations after successful CLI native persistence."""
from __future__ import annotations

import hashlib
import json
import logging
import os
from pathlib import Path
import stat
import uuid

logger = logging.getLogger(__name__)
ENVIRONMENT = 'AMPLIFIER_SESSION_CATALOG_HINT_DIRECTORY'
MAX_BYTES = 8192


def announce_saved_session(session_directory: Path) -> bool:
"""Best effort only; never affect canonical save success or start an agent.

The configured private consumer directory must already exist. Do not create
directories, read history, enumerate the spool, or select a native owner.
"""
configured = os.environ.get(ENVIRONMENT)
if not configured:
return False
directory_fd = file_fd = None
temporary = None
try:
if os.name != 'posix':
raise ValueError('Private catalog notices require POSIX')
inbox = Path(configured).expanduser()
if not inbox.is_absolute() or inbox.resolve(strict=True) != inbox:
raise ValueError('Exact existing private notice directory required')
source = Path(session_directory).resolve(strict=True)
if not source.is_dir():
raise ValueError('Saved session directory required')
raw = json.dumps({'version': 1, 'sessionDirectory': str(source)}, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
if len(raw) > MAX_BYTES:
raise ValueError('Catalog location notice exceeds 8 KiB')
directory_fd = os.open(inbox, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_NONBLOCK)
info = os.fstat(directory_fd)
if not stat.S_ISDIR(info.st_mode) or info.st_mode & 0o077 or info.st_uid != os.getuid():
raise ValueError('Same-owner private notice directory required')
# Hold the validated directory descriptor through publication so a path
# replacement cannot redirect this write into an unverified directory.
name = hashlib.sha256(str(source).encode('utf-8')).hexdigest() + '.json'
temporary = '.catalog-notice-' + uuid.uuid4().hex
file_fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=directory_fd)
offset = 0
while offset < len(raw):
count = os.write(file_fd, raw[offset:])
if count <= 0:
raise OSError('Catalog notice write did not advance')
offset += count
os.fsync(file_fd)
os.close(file_fd); file_fd = None
os.replace(temporary, name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd)
temporary = None
os.fsync(directory_fd)
return True
except Exception as exc:
# No configured paths, metadata, exception payloads or credentials in
# diagnostics. A notice failure cannot roll back native persistence.
logger.debug('Catalog notice not confirmed (%s)', type(exc).__name__)
return False
finally:
if file_fd is not None:
try:
os.close(file_fd)
except OSError:
pass
if temporary is not None and directory_fd is not None:
try:
os.unlink(temporary, dir_fd=directory_fd)
except OSError:
pass
if directory_fd is not None:
try:
os.close(directory_fd)
except OSError:
pass
10 changes: 9 additions & 1 deletion amplifier_app_cli/session_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from amplifier_foundation.session.history import SessionHistoryStore
from amplifier_foundation.session.metadata import SessionMetadataStore

from amplifier_app_cli.catalog_notices import announce_saved_session
from amplifier_app_cli.project_utils import get_project_slug
from amplifier_foundation.paths.resolution import get_amplifier_home

Expand Down Expand Up @@ -129,6 +130,7 @@ def save(self, session_id: str, transcript: list, metadata: dict) -> None:
transcript, redact_secrets(metadata), sanitizer=sanitize_message, merge_metadata=True
)

announce_saved_session(session_dir)
logger.debug(f"Session {session_id} saved successfully")

def reserve_session(self, session_id: str) -> Path:
Expand Down Expand Up @@ -159,6 +161,7 @@ def save_new(self, session_id: str, transcript: list, metadata: dict) -> None:
# write cannot delete another session's data.
shutil.rmtree(session_dir, ignore_errors=True)
raise
announce_saved_session(session_dir)
logger.debug(f"New session {session_id} saved successfully")

def _save_transcript(self, session_dir: Path, transcript: list) -> None:
Expand All @@ -171,10 +174,12 @@ def _save_transcript(self, session_dir: Path, transcript: list) -> None:
SessionHistoryStore(session_dir).save_messages(
transcript, sanitizer=sanitize_message
)
announce_saved_session(session_dir)

def _save_metadata(self, session_dir: Path, metadata: dict) -> None:
"""Save native metadata with the CLI's existing credential redaction."""
SessionHistoryStore(session_dir).save_metadata(redact_secrets(metadata), merge_metadata=True)
announce_saved_session(session_dir)

def load(self, session_id: str) -> tuple[list, dict]:
"""Load session state with corruption recovery.
Expand Down Expand Up @@ -255,13 +260,16 @@ def update_metadata(self, session_id: str, updates: dict) -> dict:

metadata = SessionMetadataStore(session_dir).update(redact_secrets(updates))

announce_saved_session(session_dir)
logger.debug(f"Session {session_id} metadata updated: {list(updates.keys())}")
return metadata

def rename(self, session_id: str, name: str) -> dict:
"""Rename through Foundation without replacing transcript/runtime state."""
self.get_metadata(session_id) # Keep strict identity/existence validation.
return SessionMetadataStore(self.base_dir / session_id).set_name(name)
metadata = SessionMetadataStore(self.base_dir / session_id).set_name(name)
announce_saved_session(self.base_dir / session_id)
return metadata

def get_metadata(self, session_id: str) -> dict:
"""Get session metadata without loading transcript.
Expand Down
17 changes: 17 additions & 0 deletions docs/contracts/catalog-location-notices.v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# CLI location notices, v1

The CLI owns optional announcements after successful native persistence. Foundation remains the owner of canonical transcript/metadata writes and shared writer locks; the independent catalog owns derived discovery. This uses the existing catalog location-only inbox contract, not a new history store or execution receipt.

`AMPLIFIER_SESSION_CATALOG_HINT_DIRECTORY` opts into an existing, canonical absolute, same-user private POSIX directory (mode 0700). An unset variable performs no notice I/O. Invalid, missing, nonprivate or symlinked destinations never prevent a successful native save. The CLI does not create the inbox, inspect its contents, start a catalog or agent, activate providers, or watch historical directories.

After `SessionStore.save`, `save_new`, `_save_transcript`, `_save_metadata`, `update_metadata` or `rename` succeeds, the CLI publishes only `{"version":1,"sessionDirectory":"/canonical/absolute/native/session/directory"}`. No title, transcript, event, credential, actor or visibility fields are included. Existing shared-root checkpoint and actual `/rename` paths delegate through these covered wrappers. Failed canonical writes emit no notice.

The compact UTF-8 body is at most 8 KiB. Its filename is SHA-256 of the canonical UTF-8 source path plus `.json`. A same-directory unique mode-0600 temporary file is fully written and fsynced, atomically replaced into that filename, then the held inbox descriptor is fsynced. Publication uses an already validated directory descriptor, preventing a later pathname replacement from redirecting writes. Repeated saves coalesce; a consumer's existing `.processing-...` claim is preserved. Temporary cleanup and notice failures never roll back native persistence. Notice delivery is best effort and diagnostic output contains only exception class, not configured paths or source payloads.

The consumer validates configured native roots and rereads current metadata. A notice is neither proof of native save outcome nor deletion authority. Physical CLI deletion is outside this producer patch. It must not manufacture another owner's `lifecycle.json`, mark nativeDeleted, change host productHidden, or treat missing metadata as an authoritative tombstone. Existing configured native-owner lifecycle markers remain separately identity checked by the catalog. Default/background metadata reconciliation can repair missed notices, but synthetic consumer acceptance does not establish adoption by older installed CLI versions.

## Reviewed implementations and checks

The local ecosystem catalog `amplifier/docs/MODULES.md` identifies CLI, Foundation and independent application ownership. The CLI baseline is `5aaafb478d02cee8c396967b954299a08e8a1efd`. All CLI-owned `SessionHistoryStore` and `SessionMetadataStore` write call sites are centralized in the wrappers above; `SharedRootSession.checkpoint` calls native_store.save. Native ACP and Foundation save implementations remain unchanged. No portable runtime module or bundle asset is added.

Tests use the installed Foundation dependency and cover all write variants, actual CLI rename, source-byte preservation, disabled/malformed/private-directory gates, bounded exact payloads, atomic/fsynced publication, consumer-claim preservation, opened-directory replacement, canonical save failure and notice failure. `scripts/qualify_catalog_notices.py` additionally requires an independently installed catalog and exercises actual installed CLI save/rename, coalesced updates and configured native-owner lifecycle proof on isolated physical fixtures. That fixture does not qualify real CLI deletion, provider/account work, browser/device rendering, or live installation.
74 changes: 74 additions & 0 deletions scripts/qualify_catalog_notices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Real installed CLI producer plus separately installed catalog; fixture-only."""
import argparse
import asyncio
import hashlib
import json
import os
from pathlib import Path
import stat
import tempfile
from types import SimpleNamespace

from amplifier_app_cli.catalog_notices import ENVIRONMENT
from amplifier_app_cli.main import CommandProcessor
from amplifier_app_cli.session_store import SessionStore
from amplifier_session_catalog import Catalog
from amplifier_session_catalog.discovery import Discovery
from amplifier_session_catalog.hints import HintInbox


def main():
parser=argparse.ArgumentParser();parser.add_argument('--output',required=True);args=parser.parse_args()
report={'scope':'Installed CLI save/rename producer and independently installed catalog on isolated physical sources; no agents/provider calls/live mutation',
'cliPackage':__import__('amplifier_app_cli').__file__,'catalogPackage':__import__('amplifier_session_catalog').__file__}
with tempfile.TemporaryDirectory(prefix='cli-catalog-producer-') as temporary:
root=Path(temporary).resolve();workspace=root/'workspace';workspace.mkdir();home=root/'native';inbox=root/'hints';inbox.mkdir(mode=0o700)
previous={key:os.environ.get(key) for key in (ENVIRONMENT,'AMPLIFIER_HOME')};cwd=Path.cwd()
os.environ[ENVIRONMENT]=str(inbox);os.environ['AMPLIFIER_HOME']=str(home);os.chdir(workspace)
try:
store=SessionStore();catalog=Catalog(root/'index.sqlite');app=root/'configured-native-owner';discovery=Discovery(catalog,[home],app_homes=[app]);consumer=HintInbox(inbox,discovery)
store.save_new('selected',[{'role':'user','content':'Canonical selected text'}],{'working_dir':str(workspace),'name':'Initial'})
directory=store.base_dir/'selected';transcript=(directory/'transcript.jsonl').read_bytes();history_digest=hashlib.sha256(transcript).hexdigest()
assert consumer.drain(limit=1)['processed']==1;consumer.close()
selected=catalog.list(connectionId='producer')['items'][0];assert selected['nativeSessionId']=='selected'
# Actual /rename implementation, not a substitute metadata mutation.
processor=CommandProcessor.__new__(CommandProcessor);processor.session=SimpleNamespace(coordinator=SimpleNamespace(session_id='selected'))
assert 'CLI name' in asyncio.run(processor._rename_session('CLI name'))
for number in range(5):store.update_metadata('selected',{'name':'CLI name','description':f'Coalesced {number}'})
notices=list(inbox.iterdir());assert len(notices)==1 and notices[0].stat().st_size<=8192 and stat.S_IMODE(notices[0].stat().st_mode)==0o600
value=json.loads(notices[0].read_text());assert value=={'version':1,'sessionDirectory':str(directory.resolve())}
opens=[];original=os.open
def guarded(path,*a,**k):
path=Path(path);assert path.name not in {'events.jsonl','transcript.jsonl','transcript.jsonl.backup'}
if path.name in {'metadata.json','metadata.json.backup','lifecycle.json'}:opens.append(str(path))
return original(path,*a,**k)
os.open=guarded
try:assert consumer.drain(limit=1)['processed']==1
finally:os.open=original;consumer.close()
assert len(opens)==1 and catalog.get(selected['uri'])['title']=='CLI name' and catalog.get(selected['uri'])['description']=='Coalesced 4'
assert (directory/'transcript.jsonl').read_bytes()==transcript
# Existing configured native owner supplies lifecycle authority;
# the CLI notice carries only location and does not forge tombstones.
marker=app/'sessions'/'selected'/'lifecycle.json';marker.parent.mkdir(parents=True)
marker.write_text(json.dumps({'sessionId':'selected','historyCwd':str(workspace),'deleted':True,'commandId':'native-owner-delete'}))
store.update_metadata('selected',{'description':'Owner deletion notice trigger'})
assert consumer.drain(limit=1)['processed']==1;consumer.close();assert catalog.get(selected['uri'])['nativeDeleted'] and not catalog.list(connectionId='deleted')['items']
marker.write_text(json.dumps({'sessionId':'selected','historyCwd':str(workspace),'deleted':False,'commandId':'native-owner-restore'}))
store.update_metadata('selected',{'description':'Owner restoration notice trigger'})
assert consumer.drain(limit=1)['processed']==1;consumer.close();assert not catalog.get(selected['uri'])['nativeDeleted']
# A producer failure cannot veto the CLI canonical save.
os.environ[ENVIRONMENT]=str(root/'uncreated-inbox')
store.rename('selected','Canonical success despite missing inbox');assert store.get_metadata('selected')['name']=='Canonical success despite missing inbox' and not (root/'uncreated-inbox').exists()
assert (directory/'transcript.jsonl').read_bytes()==transcript
report.update(newSession='discovered from actual CLI save',rename='actual CLI /rename action discovered',coalescedUpdates=5,coalescedNoticeFiles=1,renameConsumerMetadataReads=1,transcriptSha256=history_digest,canonicalHistoryPreserved=True,
nativeOwnerTombstone='existing configured owner proof consumed; CLI emitted no lifecycle authority',nativeOwnerRestore='consumed',failedOptIn='canonical save succeeded; missing inbox not created',nativeTranscriptEventReadsByNoticeConsumer=0,agentsStarted=0,providerCalls=0)
report['limits']=['Physical legacy CLI delete has no authoritative tombstone integration in this patch','Existing tombstone fixture is supplied by configured native owner, not invented by CLI','No watcher/background historical directory tracking','No browser/device/real-account/live deployment acceptance']
finally:
os.chdir(cwd)
for key,value in previous.items():
if value is None:os.environ.pop(key,None)
else:os.environ[key]=value
Path(args.output).write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report))


if __name__=='__main__':main()
Loading
Loading