diff --git a/CLAUDE.md b/CLAUDE.md index 918c13f..d731580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ The CLI uses resource groups as top-level subcommands (e.g. `folders`, `document Top-level commands outside resource groups: `login`, `logout`, `whoami`, `settings`. -Resource groups: `folders`, `documents`, `document-versions`, `sections`, `chunks`, `tags`, `workflows`, `tenants`, `users`, `permissions`, `invites`, `threads`, `thread-messages`, `chunk-lineages`, `path-parts`. +Resource groups: `folders`, `documents`, `document-versions`, `sections`, `chunks`, `tags`, `workflows`, `workflow-definitions`, `workflow-memory`, `events`, `tenants`, `users`, `permissions`, `invites`, `threads`, `thread-messages`, `chunk-lineages`, `path-parts`. ### Resource command modules (`src/kscli/commands/`) diff --git a/pyproject.toml b/pyproject.toml index fc95762..568c204 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ urls = { Repository = "https://github.com/knowledgestack/ks-cli" } dependencies = [ "certifi>=2026.1.4", "click>=8.3.1", - "ksapi>=1.25.0", + "ksapi>=1.84.0", "rich>=14.3.3", ] diff --git a/src/kscli/cli.py b/src/kscli/cli.py index 029a8b8..4d573c9 100644 --- a/src/kscli/cli.py +++ b/src/kscli/cli.py @@ -11,6 +11,7 @@ from kscli.commands.chunks import chunks from kscli.commands.document_versions import document_versions from kscli.commands.documents import documents +from kscli.commands.events import events from kscli.commands.folders import folders from kscli.commands.invites import invites from kscli.commands.path_parts import path_parts @@ -21,6 +22,8 @@ from kscli.commands.thread_messages import thread_messages from kscli.commands.threads import threads from kscli.commands.users import users +from kscli.commands.workflow_definitions import workflow_definitions +from kscli.commands.workflow_memory import workflow_memory from kscli.commands.workflows import workflows from kscli.config import ensure_config, get_default_format @@ -133,6 +136,9 @@ def main(ctx, format_, no_header, base_url): # noqa: ARG001 — params required main.add_command(chunks) main.add_command(tags) main.add_command(workflows) +main.add_command(workflow_definitions) +main.add_command(workflow_memory) +main.add_command(events) main.add_command(tenants) main.add_command(users) main.add_command(permissions) diff --git a/src/kscli/commands/chunk_lineages.py b/src/kscli/commands/chunk_lineages.py index 9f34e4d..ad1b3a1 100644 --- a/src/kscli/commands/chunk_lineages.py +++ b/src/kscli/commands/chunk_lineages.py @@ -5,6 +5,10 @@ from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result +from kscli.utils.checkout import ( + resolve_ancestor_document_path_part_id, + with_document_checkout, +) @click.group("chunk-lineages") @@ -29,16 +33,21 @@ def describe_chunk_lineage(ctx, chunk_id): @click.option("--child-chunk-id", type=click.UUID, required=True) @click.pass_context def create_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): - """Create a lineage link.""" + """Create a lineage link. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + child = ksapi.ChunksApi(api_client).get_chunk(child_chunk_id) + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, child.path_part_id + ) api = ksapi.ChunkLineagesApi(api_client) - result = api.create_chunk_lineage( - ksapi.CreateChunkLineageRequest( - chunk_id=child_chunk_id, - parent_chunk_ids=[parent_chunk_id], + with with_document_checkout(api_client, doc_path_part_id): + result = api.create_chunk_lineage( + ksapi.CreateChunkLineageRequest( + chunk_id=child_chunk_id, + parent_chunk_ids=[parent_chunk_id], + ) ) - ) print_result(ctx, [r.model_dump(mode="json") for r in result]) @@ -47,12 +56,17 @@ def create_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): @click.option("--child-chunk-id", type=click.UUID, required=True) @click.pass_context def delete_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): - """Delete a lineage link.""" + """Delete a lineage link. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): - api = ksapi.ChunkLineagesApi(api_client) - api.delete_chunk_lineage( - parent_chunk_id=parent_chunk_id, - chunk_id=child_chunk_id, + child = ksapi.ChunksApi(api_client).get_chunk(child_chunk_id) + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, child.path_part_id ) + api = ksapi.ChunkLineagesApi(api_client) + with with_document_checkout(api_client, doc_path_part_id): + api.delete_chunk_lineage( + parent_chunk_id=parent_chunk_id, + chunk_id=child_chunk_id, + ) click.echo("Deleted chunk lineage link") diff --git a/src/kscli/commands/chunks.py b/src/kscli/commands/chunks.py index cc7ee3b..c37652d 100644 --- a/src/kscli/commands/chunks.py +++ b/src/kscli/commands/chunks.py @@ -7,6 +7,10 @@ from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result +from kscli.utils.checkout import ( + resolve_ancestor_document_path_part_id, + with_document_checkout, +) _SEARCH_FILTER_KEYS = { "model", @@ -52,7 +56,7 @@ def describe_chunk(ctx, chunk_id): @click.option("--metadata", "meta", default=None, help="JSON string of metadata") @click.pass_context def create_chunk(ctx, content, version_id, section_id, chunk_type, meta): - """Create a chunk.""" + """Create a chunk. Acquires a document checkout for the duration.""" if version_id is not None and section_id is not None: raise click.UsageError("Provide only one of --version-id or --section-id") parent_path_id = version_id or section_id @@ -60,20 +64,23 @@ def create_chunk(ctx, content, version_id, section_id, chunk_type, meta): raise click.UsageError("Provide either --version-id or --section-id") api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, parent_path_id + ) api = ksapi.ChunksApi(api_client) metadata = json.loads(meta) if meta else None chunk_metadata = ( - ksapi.ChunkMetadataInput.from_dict(metadata or {}) - or ksapi.ChunkMetadataInput() + ksapi.ChunkMetadata.from_dict(metadata or {}) or ksapi.ChunkMetadata() ) - result = api.create_chunk( - ksapi.CreateChunkRequest( - parent_path_id=parent_path_id, - content=content, - chunk_type=chunk_type, - chunk_metadata=chunk_metadata, + with with_document_checkout(api_client, doc_path_part_id): + result = api.create_chunk( + ksapi.CreateChunkRequest( + parent_path_id=parent_path_id, + content=content, + chunk_type=chunk_type, + chunk_metadata=chunk_metadata, + ) ) - ) print_result(ctx, result.model_dump(mode="json")) @@ -82,19 +89,23 @@ def create_chunk(ctx, content, version_id, section_id, chunk_type, meta): @click.option("--metadata", "meta", default=None, help="JSON string of metadata") @click.pass_context def update_chunk(ctx, chunk_id, meta): - """Update chunk metadata.""" + """Update chunk metadata. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.ChunksApi(api_client) + chunk = api.get_chunk(chunk_id) + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, chunk.path_part_id + ) metadata = json.loads(meta) if meta else None chunk_metadata = ( - ksapi.ChunkMetadataInput.from_dict(metadata or {}) - or ksapi.ChunkMetadataInput() - ) - result = api.update_chunk_metadata( - chunk_id, - ksapi.UpdateChunkMetadataRequest(chunk_metadata=chunk_metadata), + ksapi.ChunkMetadata.from_dict(metadata or {}) or ksapi.ChunkMetadata() ) + with with_document_checkout(api_client, doc_path_part_id): + result = api.update_chunk_metadata( + chunk_id, + ksapi.UpdateChunkMetadataRequest(chunk_metadata=chunk_metadata), + ) print_result(ctx, result.model_dump(mode="json")) @@ -103,14 +114,19 @@ def update_chunk(ctx, chunk_id, meta): @click.option("--content", required=True) @click.pass_context def update_chunk_content(ctx, chunk_id, content): - """Update chunk content.""" + """Update chunk content. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.ChunksApi(api_client) - result = api.update_chunk_content( - chunk_id, - ksapi.UpdateChunkContentRequest(content=content), + chunk = api.get_chunk(chunk_id) + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, chunk.path_part_id ) + with with_document_checkout(api_client, doc_path_part_id): + result = api.update_chunk_content( + chunk_id, + ksapi.UpdateChunkContentRequest(content=content), + ) print_result(ctx, result.model_dump(mode="json")) @@ -118,11 +134,16 @@ def update_chunk_content(ctx, chunk_id, content): @click.argument("chunk_id", type=click.UUID) @click.pass_context def delete_chunk(ctx, chunk_id): - """Delete a chunk.""" + """Delete a chunk. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.ChunksApi(api_client) - api.delete_chunk(chunk_id) + chunk = api.get_chunk(chunk_id) + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, chunk.path_part_id + ) + with with_document_checkout(api_client, doc_path_part_id): + api.delete_chunk(chunk_id) click.echo(f"Deleted chunk {chunk_id}") diff --git a/src/kscli/commands/document_versions.py b/src/kscli/commands/document_versions.py index 7a04c91..4178be4 100644 --- a/src/kscli/commands/document_versions.py +++ b/src/kscli/commands/document_versions.py @@ -5,6 +5,11 @@ from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result +from kscli.utils.checkout import ( + resolve_document_path_part_id, + resolve_version_document_path_part_id, + with_document_checkout, +) COLUMNS = ["id", "document_id", "name", "created_at"] @@ -75,11 +80,13 @@ def version_contents(ctx, version_id, show_content, sections_only): @click.option("--document-id", type=click.UUID, required=True) @click.pass_context def create_version(ctx, document_id): - """Create a new version.""" + """Create a new version. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_document_path_part_id(api_client, document_id) api = ksapi.DocumentVersionsApi(api_client) - result = api.create_document_version(document_id=document_id) + with with_document_checkout(api_client, doc_path_part_id): + result = api.create_document_version(document_id=document_id) print_result(ctx, result.model_dump(mode="json")) @@ -88,14 +95,16 @@ def create_version(ctx, document_id): @click.option("--source-s3", default=None) @click.pass_context def update_version(ctx, version_id, source_s3): - """Update version metadata.""" + """Update version metadata. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_version_document_path_part_id(api_client, version_id) api = ksapi.DocumentVersionsApi(api_client) - result = api.update_document_version_metadata( - version_id, - ksapi.DocumentVersionMetadataUpdate(source_s3=source_s3), - ) + with with_document_checkout(api_client, doc_path_part_id): + result = api.update_document_version_metadata( + version_id, + ksapi.DocumentVersionMetadataUpdate(source_s3=source_s3), + ) print_result(ctx, result.model_dump(mode="json")) @@ -103,11 +112,13 @@ def update_version(ctx, version_id, source_s3): @click.argument("version_id", type=click.UUID) @click.pass_context def delete_version(ctx, version_id): - """Delete a version.""" + """Delete a version. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_version_document_path_part_id(api_client, version_id) api = ksapi.DocumentVersionsApi(api_client) - api.delete_document_version(version_id) + with with_document_checkout(api_client, doc_path_part_id): + api.delete_document_version(version_id) click.echo(f"Deleted version {version_id}") @@ -115,9 +126,11 @@ def delete_version(ctx, version_id): @click.argument("version_id", type=click.UUID) @click.pass_context def clear_version_contents(ctx, version_id): - """Clear all contents under a version.""" + """Clear all contents under a version. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_version_document_path_part_id(api_client, version_id) api = ksapi.DocumentVersionsApi(api_client) - api.clear_document_version_contents(version_id) + with with_document_checkout(api_client, doc_path_part_id): + api.clear_document_version_contents(version_id) click.echo(f"Cleared contents of version {version_id}") diff --git a/src/kscli/commands/documents.py b/src/kscli/commands/documents.py index c547e3e..a85fe79 100644 --- a/src/kscli/commands/documents.py +++ b/src/kscli/commands/documents.py @@ -7,6 +7,10 @@ from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result +from kscli.utils.checkout import ( + resolve_document_path_part_id, + with_document_checkout, +) COLUMNS = ["id", "name", "type", "origin", "parent_path_part_id", "created_at"] @@ -98,18 +102,20 @@ def create_document(ctx, name, parent_path_part_id, doc_type, origin): @click.option("--active-version-id", type=click.UUID, default=None) @click.pass_context def update_document(ctx, document_id, name, parent_path_part_id, active_version_id): - """Update a document.""" + """Update a document. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_document_path_part_id(api_client, document_id) api = ksapi.DocumentsApi(api_client) - result = api.update_document( - document_id, - ksapi.UpdateDocumentRequest( - name=name, - parent_path_part_id=parent_path_part_id, - active_version_id=active_version_id, - ), - ) + with with_document_checkout(api_client, doc_path_part_id): + result = api.update_document( + document_id, + ksapi.UpdateDocumentRequest( + name=name, + parent_path_part_id=parent_path_part_id, + active_version_id=active_version_id, + ), + ) print_result(ctx, result.model_dump(mode="json")) @@ -117,11 +123,13 @@ def update_document(ctx, document_id, name, parent_path_part_id, active_version_ @click.argument("document_id", type=click.UUID) @click.pass_context def delete_document(ctx, document_id): - """Delete a document.""" + """Delete a document. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_document_path_part_id(api_client, document_id) api = ksapi.DocumentsApi(api_client) - api.delete_document(document_id) + with with_document_checkout(api_client, doc_path_part_id): + api.delete_document(document_id) click.echo(f"Deleted document {document_id}") diff --git a/src/kscli/commands/events.py b/src/kscli/commands/events.py new file mode 100644 index 0000000..bc1efb3 --- /dev/null +++ b/src/kscli/commands/events.py @@ -0,0 +1,101 @@ +"""Event (audit log) commands.""" + +import datetime +import json +from typing import Any + +import click +import ksapi + +from kscli.client import get_api_client, handle_client_errors +from kscli.output import print_result + +COLUMNS = ["id", "subject_path_part_id", "kind", "ts", "actor_user_id"] + + +def _parse_payload(raw: str | None) -> dict[str, Any] | None: + if raw is None: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise click.UsageError(f"--payload must be valid JSON: {e}") from e + if not isinstance(data, dict): + raise click.UsageError("--payload must be a JSON object.") + return data + + +@click.group("events") +def events(): + """Audit-log events recorded against a path part.""" + + +@events.command("list") +@click.argument("path_part_id", type=click.UUID) +@click.option("--kind", default=None, help="Filter to a single event kind.") +@click.option( + "--since", + type=click.DateTime(), + default=None, + help="Only events at or after this timestamp (ISO 8601).", +) +@click.option( + "--until", + type=click.DateTime(), + default=None, + help="Only events strictly before this timestamp (ISO 8601).", +) +@click.option( + "--recursive/--no-recursive", + default=False, + show_default=True, + help="Include events from descendant path parts.", +) +@click.option("--limit", "-l", type=click.IntRange(1, 100), default=20) +@click.option("--offset", "-o", type=int, default=0) +@click.pass_context +def list_events(ctx, path_part_id, kind, since, until, recursive, limit, offset): + """List events for a path part.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.PathPartsApi(api_client) + result = api.list_path_part_events( + path_part_id, + kind=kind, + since=_with_utc(since), + until=_with_utc(until), + recursive=recursive, + limit=limit, + offset=offset, + ) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) + + +@events.command("append") +@click.argument("path_part_id", type=click.UUID) +@click.option("--kind", "-k", required=True, help="Event kind (1-255 chars).") +@click.option( + "--payload", + "-p", + default=None, + help="Optional JSON object payload, e.g. '{\"k\":\"v\"}'.", +) +@click.pass_context +def append_event(ctx, path_part_id, kind, payload): + """Append an audit-log event to a path part.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.PathPartsApi(api_client) + result = api.append_path_part_event( + path_part_id, + ksapi.AppendEventRequest(kind=kind, payload=_parse_payload(payload)), + ) + print_result(ctx, result.model_dump(mode="json")) + + +def _with_utc(dt: datetime.datetime | None) -> datetime.datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=datetime.UTC) + return dt diff --git a/src/kscli/commands/sections.py b/src/kscli/commands/sections.py index 5fa169c..96c2914 100644 --- a/src/kscli/commands/sections.py +++ b/src/kscli/commands/sections.py @@ -5,6 +5,10 @@ from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result +from kscli.utils.checkout import ( + resolve_ancestor_document_path_part_id, + with_document_checkout, +) @click.group("sections") @@ -31,18 +35,22 @@ def describe_section(ctx, section_id): @click.option("--prev-sibling-path-id", type=click.UUID, default=None) @click.pass_context def create_section(ctx, name, parent_path_id, page_number, prev_sibling_path_id): - """Create a section.""" + """Create a section. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, parent_path_id + ) api = ksapi.SectionsApi(api_client) - result = api.create_section( - ksapi.CreateSectionRequest( - name=name, - parent_path_id=parent_path_id, - page_number=page_number, - prev_sibling_path_id=prev_sibling_path_id, + with with_document_checkout(api_client, doc_path_part_id): + result = api.create_section( + ksapi.CreateSectionRequest( + name=name, + parent_path_id=parent_path_id, + page_number=page_number, + prev_sibling_path_id=prev_sibling_path_id, + ) ) - ) print_result(ctx, result.model_dump(mode="json")) @@ -56,19 +64,24 @@ def create_section(ctx, name, parent_path_id, page_number, prev_sibling_path_id) def update_section( ctx, section_id, name, page_number, prev_sibling_path_id, move_to_head ): - """Update a section.""" + """Update a section. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.SectionsApi(api_client) - result = api.update_section( - section_id, - ksapi.UpdateSectionRequest( - name=name, - page_number=page_number, - prev_sibling_path_id=prev_sibling_path_id, - move_to_head=move_to_head, - ), + section = api.get_section(section_id) + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, section.path_part_id ) + with with_document_checkout(api_client, doc_path_part_id): + result = api.update_section( + section_id, + ksapi.UpdateSectionRequest( + name=name, + page_number=page_number, + prev_sibling_path_id=prev_sibling_path_id, + move_to_head=move_to_head, + ), + ) print_result(ctx, result.model_dump(mode="json")) @@ -76,9 +89,14 @@ def update_section( @click.argument("section_id", type=click.UUID) @click.pass_context def delete_section(ctx, section_id): - """Delete a section.""" + """Delete a section. Acquires a document checkout for the duration.""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.SectionsApi(api_client) - api.delete_section(section_id) + section = api.get_section(section_id) + doc_path_part_id = resolve_ancestor_document_path_part_id( + api_client, section.path_part_id + ) + with with_document_checkout(api_client, doc_path_part_id): + api.delete_section(section_id) click.echo(f"Deleted section {section_id}") diff --git a/src/kscli/commands/tags.py b/src/kscli/commands/tags.py index b42b923..b54edf5 100644 --- a/src/kscli/commands/tags.py +++ b/src/kscli/commands/tags.py @@ -96,9 +96,13 @@ def attach_tag(ctx, tag_id, path_part_id): api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.PathPartsApi(api_client) - result = api.bulk_add_path_part_tags( + current = api.get_path_part_tags(path_part_id, include_inherited=False) + existing_ids = [t.id for t in current.tags] + if tag_id not in existing_ids: + existing_ids.append(tag_id) + result = api.set_path_part_tags( path_part_id, - ksapi.BulkTagRequest(tag_ids=[tag_id]), + ksapi.BulkTagRequest(tag_ids=existing_ids), ) print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/commands/workflow_definitions.py b/src/kscli/commands/workflow_definitions.py new file mode 100644 index 0000000..b007efd --- /dev/null +++ b/src/kscli/commands/workflow_definitions.py @@ -0,0 +1,292 @@ +"""Workflow definition commands.""" + +import json + +import click +import ksapi + +from kscli.client import get_api_client, handle_client_errors +from kscli.output import print_result + +COLUMNS = [ + "id", + "name", + "runner_type", + "is_active", + "approval_required", + "max_run_duration_seconds", + "created_at", +] + +RUN_COLUMNS = [ + "id", + "workflow_definition_id", + "status", + "runner_type", + "started_at", + "completed_at", + "created_at", +] + +_RUNNER_TYPES = [t.value for t in ksapi.WorkflowRunnerType] + + +def _parse_runner_config(raw: str | None) -> ksapi.SelfHostedRunnerConfig | None: + if raw is None: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise click.UsageError(f"--runner-config must be valid JSON: {e}") from e + if not isinstance(data, dict): + raise click.UsageError("--runner-config must be a JSON object.") + return ksapi.SelfHostedRunnerConfig(**data) + + +@click.group("workflow-definitions") +def workflow_definitions(): + """Manage workflow definitions.""" + + +@workflow_definitions.command("list") +@click.option("--limit", "-l", type=int, default=20) +@click.option("--offset", "-o", type=int, default=0) +@click.pass_context +def list_workflow_definitions(ctx, limit, offset): + """List workflow definitions.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowDefinitionsApi(api_client) + result = api.list_workflow_definitions(limit=limit, offset=offset) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) + + +@workflow_definitions.command("describe") +@click.argument("definition_id", type=click.UUID) +@click.pass_context +def describe_workflow_definition(ctx, definition_id): + """Describe a workflow definition.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowDefinitionsApi(api_client) + result = api.get_workflow_definition(definition_id) + print_result(ctx, result.model_dump(mode="json")) + + +@workflow_definitions.command("create") +@click.option("--name", "-n", required=True, help="Workflow name (max 255 chars).") +@click.option("--description", "-d", default=None) +@click.option( + "--runner-type", + type=click.Choice(_RUNNER_TYPES), + default=_RUNNER_TYPES[0] if _RUNNER_TYPES else None, + show_default=True, +) +@click.option( + "--runner-config", + default=None, + help='JSON object for the runner config, e.g. \'{"url": "...", "webhook_secret": "..."}\'.', +) +@click.option( + "--max-run-duration-seconds", + type=click.IntRange(60, 86400), + default=300, + show_default=True, +) +@click.option( + "--source-path-part-id", + "source_path_part_ids", + type=click.UUID, + multiple=True, + required=True, + help="Source path part ID (repeatable, 1-20).", +) +@click.option( + "--instruction-path-part-id", + "instruction_path_part_ids", + type=click.UUID, + multiple=True, + required=True, + help="Instruction path part ID (repeatable, 1-20).", +) +@click.option( + "--output-path-part-id", + "output_path_part_ids", + type=click.UUID, + multiple=True, + required=True, + help="Output path part ID (repeatable, 1-20).", +) +@click.option( + "--template-path-part-id", + type=click.UUID, + default=None, + help="Optional template path part ID.", +) +@click.pass_context +def create_workflow_definition( + ctx, + name, + description, + runner_type, + runner_config, + max_run_duration_seconds, + source_path_part_ids, + instruction_path_part_ids, + output_path_part_ids, + template_path_part_id, +): + """Create a workflow definition.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowDefinitionsApi(api_client) + result = api.create_workflow_definition( + ksapi.CreateWorkflowDefinitionRequest( + name=name, + description=description, + runner_type=ksapi.WorkflowRunnerType(runner_type), + runner_config=_parse_runner_config(runner_config), + max_run_duration_seconds=max_run_duration_seconds, + source_path_part_ids=list(source_path_part_ids), + instruction_path_part_ids=list(instruction_path_part_ids), + output_path_part_ids=list(output_path_part_ids), + template_path_part_id=template_path_part_id, + ) + ) + print_result(ctx, result.model_dump(mode="json")) + + +@workflow_definitions.command("update") +@click.argument("definition_id", type=click.UUID) +@click.option("--name", "-n", required=True) +@click.option("--description", "-d", default=None) +@click.option( + "--runner-type", + type=click.Choice(_RUNNER_TYPES), + default=_RUNNER_TYPES[0] if _RUNNER_TYPES else None, + show_default=True, +) +@click.option("--runner-config", default=None, help="JSON object for the runner config.") +@click.option( + "--max-run-duration-seconds", + type=click.IntRange(60, 86400), + default=300, + show_default=True, +) +@click.option( + "--source-path-part-id", + "source_path_part_ids", + type=click.UUID, + multiple=True, + required=True, +) +@click.option( + "--instruction-path-part-id", + "instruction_path_part_ids", + type=click.UUID, + multiple=True, + required=True, +) +@click.option( + "--output-path-part-id", + "output_path_part_ids", + type=click.UUID, + multiple=True, + required=True, +) +@click.option("--template-path-part-id", type=click.UUID, default=None) +@click.option( + "--is-active/--no-is-active", + "is_active", + default=True, + show_default=True, +) +@click.option( + "--approval-required/--no-approval-required", + "approval_required", + default=True, + show_default=True, +) +@click.pass_context +def update_workflow_definition( + ctx, + definition_id, + name, + description, + runner_type, + runner_config, + max_run_duration_seconds, + source_path_part_ids, + instruction_path_part_ids, + output_path_part_ids, + template_path_part_id, + is_active, + approval_required, +): + """Update a workflow definition.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowDefinitionsApi(api_client) + result = api.update_workflow_definition( + definition_id, + ksapi.UpdateWorkflowDefinitionRequest( + name=name, + description=description, + runner_type=ksapi.WorkflowRunnerType(runner_type), + runner_config=_parse_runner_config(runner_config), + max_run_duration_seconds=max_run_duration_seconds, + source_path_part_ids=list(source_path_part_ids), + instruction_path_part_ids=list(instruction_path_part_ids), + output_path_part_ids=list(output_path_part_ids), + template_path_part_id=template_path_part_id, + is_active=is_active, + approval_required=approval_required, + ), + ) + print_result(ctx, result.model_dump(mode="json")) + + +@workflow_definitions.command("delete") +@click.argument("definition_id", type=click.UUID) +@click.pass_context +def delete_workflow_definition(ctx, definition_id): + """Delete a workflow definition.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowDefinitionsApi(api_client) + api.delete_workflow_definition(definition_id) + click.echo(f"Deleted workflow definition {definition_id}") + + +@workflow_definitions.command("invoke") +@click.argument("definition_id", type=click.UUID) +@click.option( + "--idempotency-key", + default=None, + help="Optional key to prevent duplicate runs from retries (max 255 chars).", +) +@click.pass_context +def invoke_workflow(ctx, definition_id, idempotency_key): + """Invoke a workflow definition (start a new run).""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowDefinitionsApi(api_client) + result = api.invoke_workflow( + definition_id, + ksapi.InvokeWorkflowRequest(idempotency_key=idempotency_key), + ) + print_result(ctx, result.model_dump(mode="json")) + + +@workflow_definitions.command("runs") +@click.argument("definition_id", type=click.UUID) +@click.option("--limit", "-l", type=int, default=20) +@click.option("--offset", "-o", type=int, default=0) +@click.pass_context +def list_runs(ctx, definition_id, limit, offset): + """List runs for a workflow definition.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowDefinitionsApi(api_client) + result = api.list_workflow_runs(definition_id, limit=limit, offset=offset) + print_result(ctx, result.model_dump(mode="json"), columns=RUN_COLUMNS) diff --git a/src/kscli/commands/workflow_memory.py b/src/kscli/commands/workflow_memory.py new file mode 100644 index 0000000..b969f45 --- /dev/null +++ b/src/kscli/commands/workflow_memory.py @@ -0,0 +1,97 @@ +"""Workflow memory commands.""" + +import click +import ksapi + +from kscli.client import get_api_client, handle_client_errors +from kscli.output import print_result + +COLUMNS = ["chunk_id", "kind", "body"] + +_MEMORY_KINDS = [k.value for k in ksapi.MemoryKind] + + +@click.group("workflow-memory") +def workflow_memory(): + """Manage long-term memory chunks attached to a workflow definition.""" + + +@workflow_memory.command("list") +@click.argument("definition_id", type=click.UUID) +@click.pass_context +def list_memory(ctx, definition_id): + """List memory chunks for a workflow definition.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowMemoryApi(api_client) + result = api.list_workflow_memory_chunks(definition_id) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) + + +@workflow_memory.command("describe") +@click.argument("definition_id", type=click.UUID) +@click.argument("chunk_id", type=click.UUID) +@click.pass_context +def describe_memory(ctx, definition_id, chunk_id): + """Describe a single memory chunk.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowMemoryApi(api_client) + result = api.get_workflow_memory_chunk(definition_id, chunk_id) + print_result(ctx, result.model_dump(mode="json")) + + +@workflow_memory.command("append") +@click.argument("definition_id", type=click.UUID) +@click.option("--body", "-b", required=True, help="Memory body text (1-16384 chars).") +@click.option( + "--kind", + type=click.Choice(_MEMORY_KINDS), + default=None, + help="Optional memory kind.", +) +@click.pass_context +def append_memory(ctx, definition_id, body, kind): + """Append a memory chunk to a workflow definition.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowMemoryApi(api_client) + result = api.append_workflow_memory_chunk( + definition_id, + ksapi.AppendMemoryChunkRequest( + body=body, + kind=ksapi.MemoryKind(kind) if kind else None, + ), + ) + print_result(ctx, result.model_dump(mode="json")) + + +@workflow_memory.command("edit") +@click.argument("definition_id", type=click.UUID) +@click.argument("chunk_id", type=click.UUID) +@click.option("--body", "-b", required=True, help="New memory body text (1-16384 chars).") +@click.pass_context +def edit_memory(ctx, definition_id, chunk_id, body): + """Edit a memory chunk's body.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowMemoryApi(api_client) + result = api.edit_workflow_memory_chunk( + definition_id, + chunk_id, + ksapi.EditMemoryChunkRequest(body=body), + ) + print_result(ctx, result.model_dump(mode="json")) + + +@workflow_memory.command("forget") +@click.argument("definition_id", type=click.UUID) +@click.argument("chunk_id", type=click.UUID) +@click.pass_context +def forget_memory(ctx, definition_id, chunk_id): + """Forget (delete) a memory chunk.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowMemoryApi(api_client) + api.forget_workflow_memory_chunk(definition_id, chunk_id) + click.echo(f"Forgot memory chunk {chunk_id}") diff --git a/src/kscli/utils/checkout.py b/src/kscli/utils/checkout.py new file mode 100644 index 0000000..8161f14 --- /dev/null +++ b/src/kscli/utils/checkout.py @@ -0,0 +1,132 @@ +"""Document checkout helper. + +The backend requires a checkout (write lock) on a document before mutating its +versions, sections, chunks, or deleting the document itself. The current +generated `ksapi` does not expose the checkout endpoints, so this helper calls +them via the SDK's raw `param_serialize` + `call_api` path while reusing the +authenticated `ApiClient`. + +Endpoints (from the backend OpenAPI): +- ``POST /v1/documents/{path_part_id}/checkout`` — acquire or renew +- ``DELETE /v1/documents/{path_part_id}/checkout`` — release +""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager +from typing import TYPE_CHECKING + +import ksapi + +if TYPE_CHECKING: + import uuid + from collections.abc import Iterator + +_RESOURCE_PATH = "/v1/documents/{path_part_id}/checkout" +_HTTP_OK_MIN = 200 +_HTTP_OK_MAX = 300 # exclusive upper bound + +log = logging.getLogger(__name__) + + +def _raw_call( + api_client: ksapi.ApiClient, + method: str, + path_part_id: uuid.UUID, +) -> None: + """Call the checkout endpoint and raise ApiException on non-2xx.""" + method_, url, headers, body, post_params = api_client.param_serialize( + method=method, + resource_path=_RESOURCE_PATH, + path_params={"path_part_id": str(path_part_id)}, + header_params={"Accept": "application/json"}, + body=None, + auth_settings=[], + ) + response = api_client.call_api( + method_, url, header_params=headers, body=body, post_params=post_params + ) + response.read() + if not _HTTP_OK_MIN <= response.status < _HTTP_OK_MAX: + text = response.data.decode("utf-8", errors="replace") if response.data else None + raise ksapi.ApiException.from_response( + http_resp=response, body=text, data=None + ) + + +def acquire_document_checkout( + api_client: ksapi.ApiClient, path_part_id: uuid.UUID +) -> None: + """Acquire (or renew) a write lock on a document.""" + _raw_call(api_client, "POST", path_part_id) + + +def release_document_checkout( + api_client: ksapi.ApiClient, path_part_id: uuid.UUID +) -> None: + """Release a write lock on a document.""" + _raw_call(api_client, "DELETE", path_part_id) + + +@contextmanager +def with_document_checkout( + api_client: ksapi.ApiClient, path_part_id: uuid.UUID +) -> Iterator[None]: + """Acquire a document checkout for the body of the block, release on exit. + + Release errors are logged and swallowed so they don't mask the primary + operation's outcome — the lock will expire on its TTL regardless. + """ + acquire_document_checkout(api_client, path_part_id) + try: + yield + finally: + try: + release_document_checkout(api_client, path_part_id) + except ksapi.ApiException as exc: + log.debug("Failed to release checkout on %s: %s", path_part_id, exc) + + +def resolve_document_path_part_id( + api_client: ksapi.ApiClient, document_id: uuid.UUID +) -> uuid.UUID: + """Look up a document's path_part_id by document_id.""" + documents_api = ksapi.DocumentsApi(api_client) + return documents_api.get_document(document_id).path_part_id + + +def resolve_version_document_path_part_id( + api_client: ksapi.ApiClient, version_id: uuid.UUID +) -> uuid.UUID: + """Look up the parent document's path_part_id for a version.""" + versions_api = ksapi.DocumentVersionsApi(api_client) + parent = versions_api.get_document_version(version_id).parent_path_id + if parent is None: + msg = f"Version {version_id} has no parent document path_part_id" + raise ksapi.ApiException(status=404, reason=msg) + return parent + + +_MAX_ANCESTOR_WALK = 32 + + +def resolve_ancestor_document_path_part_id( + api_client: ksapi.ApiClient, path_part_id: uuid.UUID +) -> uuid.UUID: + """Walk up the path-part tree until reaching a node whose part_type is DOCUMENT. + + Use when starting from a section, chunk, or version path_part_id — anything + below a document in the path tree. + """ + path_parts_api = ksapi.PathPartsApi(api_client) + current = path_part_id + for _ in range(_MAX_ANCESTOR_WALK): + part = path_parts_api.get_path_part(current) + if part.part_type == "DOCUMENT": + return part.path_part_id + if part.parent_path_id is None: + break + current = part.parent_path_id + msg = f"No DOCUMENT ancestor found for path_part {path_part_id}" + raise ksapi.ApiException(status=404, reason=msg) diff --git a/tests/e2e/test_cli_events.py b/tests/e2e/test_cli_events.py new file mode 100644 index 0000000..2bc0d68 --- /dev/null +++ b/tests/e2e/test_cli_events.py @@ -0,0 +1,33 @@ +"""E2E tests for events (audit log) commands (read-only).""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import NONEXISTENT_UUID, SHARED_FOLDER_PATH_PART_ID + +pytestmark = pytest.mark.e2e + + +class TestCliEvents: + """Read-only events tests.""" + + def test_list_events_for_path_part( + self, cli_authenticated: dict[str, str] + ) -> None: + """List events for an existing path part returns a paginated list.""" + result = run_kscli_ok( + ["events", "list", SHARED_FOLDER_PATH_PART_ID], + env=cli_authenticated, + ) + data = result.json_output + assert isinstance(data, dict) + assert isinstance(data["items"], list) + + def test_list_events_for_nonexistent_path_part( + self, cli_authenticated: dict[str, str] + ) -> None: + """Listing events for a nonexistent path part returns error.""" + run_kscli_fail( + ["events", "list", NONEXISTENT_UUID], + env=cli_authenticated, + ) diff --git a/tests/e2e/test_cli_workflow_definitions.py b/tests/e2e/test_cli_workflow_definitions.py new file mode 100644 index 0000000..e3a2773 --- /dev/null +++ b/tests/e2e/test_cli_workflow_definitions.py @@ -0,0 +1,30 @@ +"""E2E tests for workflow-definitions commands (read-only).""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import NONEXISTENT_UUID + +pytestmark = pytest.mark.e2e + + +class TestCliWorkflowDefinitions: + """Read-only workflow-definitions tests.""" + + def test_list_workflow_definitions(self, cli_authenticated: dict[str, str]) -> None: + """List workflow definitions returns a list (may be empty).""" + result = run_kscli_ok( + ["workflow-definitions", "list"], env=cli_authenticated + ) + data = result.json_output + assert isinstance(data, dict) + assert isinstance(data["items"], list) + + def test_describe_workflow_definition_not_found( + self, cli_authenticated: dict[str, str] + ) -> None: + """Describe a nonexistent workflow definition returns error.""" + run_kscli_fail( + ["workflow-definitions", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + ) diff --git a/tests/e2e/test_cli_workflow_memory.py b/tests/e2e/test_cli_workflow_memory.py new file mode 100644 index 0000000..4821825 --- /dev/null +++ b/tests/e2e/test_cli_workflow_memory.py @@ -0,0 +1,30 @@ +"""E2E tests for workflow-memory commands (read-only).""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail +from tests.e2e.conftest import NONEXISTENT_UUID + +pytestmark = pytest.mark.e2e + + +class TestCliWorkflowMemory: + """Read-only workflow-memory tests.""" + + def test_list_memory_nonexistent_definition( + self, cli_authenticated: dict[str, str] + ) -> None: + """Listing memory for a nonexistent definition returns error.""" + run_kscli_fail( + ["workflow-memory", "list", NONEXISTENT_UUID], + env=cli_authenticated, + ) + + def test_describe_memory_nonexistent( + self, cli_authenticated: dict[str, str] + ) -> None: + """Describing a nonexistent memory chunk returns error.""" + run_kscli_fail( + ["workflow-memory", "describe", NONEXISTENT_UUID, NONEXISTENT_UUID], + env=cli_authenticated, + ) diff --git a/uv.lock b/uv.lock index d9d5464..287bea8 100644 --- a/uv.lock +++ b/uv.lock @@ -109,7 +109,7 @@ wheels = [ [[package]] name = "ksapi" -version = "1.66.3" +version = "1.84.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -117,9 +117,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/40/8fd0ed7ceaa1e6654cbb7cf3d1bad022243fce7141764a61f5499c2340fa/ksapi-1.66.3.tar.gz", hash = "sha256:43447965cc8c51158b51c61c7b054452706003b11b203dc477fae8c5cbf1ae1e", size = 180153, upload-time = "2026-03-30T16:05:13.076Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2a/c7772c1c44e9ed5a0e61d3a659d3a7d1bcc23e265a3a95686d6422f0138c/ksapi-1.84.0.tar.gz", hash = "sha256:731e81ee62efa6364ab6db04acb99757c5549f51ce81453a62def2285ca5e07a", size = 215607, upload-time = "2026-05-11T06:39:42.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/fc/81c285ca9eb085e365dca4c0567b30fd45f9e5acb026c4018aca22a956df/ksapi-1.66.3-py3-none-any.whl", hash = "sha256:f1e4e594ccca591276d1db129d5001e29427ec383696061f43413367af97c87b", size = 363601, upload-time = "2026-03-30T16:05:11.652Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/fc885b970704b02f7c02de0348eee96fb7abcfefecaff94658ff700a4f46/ksapi-1.84.0-py3-none-any.whl", hash = "sha256:5b40be896ea7ad70e99b852799ac5592843b867cf2a197422db52e137de6ae69", size = 428722, upload-time = "2026-05-11T06:39:41.318Z" }, ] [[package]] @@ -146,7 +146,7 @@ dev = [ requires-dist = [ { name = "certifi", specifier = ">=2026.1.4" }, { name = "click", specifier = ">=8.3.1" }, - { name = "ksapi", specifier = ">=1.25.0" }, + { name = "ksapi", specifier = ">=1.84.0" }, { name = "rich", specifier = ">=14.3.3" }, ]