From 90f80d3b1c8d44190dec762a8202355e13503f3d Mon Sep 17 00:00:00 2001 From: Weiming Liao Date: Mon, 2 Nov 2026 23:00:46 +0800 Subject: [PATCH] [feat] core: publish execution artifacts through commands Signed-off-by: Weiming Liao --- apps/api/src/opendirector_api/creation.py | 2 + .../application/creation_artifacts.py | 66 +++++ .../application/creation_assets.py | 8 +- .../opendirector/domain/creation/commands.py | 9 + src/contracts/creation.schema.json | 228 ++++++++++++++++++ src/contracts/generated.ts | 32 ++- tests/test_creation_artifacts.py | 169 +++++++++++++ 7 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/opendirector/application/creation_artifacts.py create mode 100644 tests/test_creation_artifacts.py diff --git a/apps/api/src/opendirector_api/creation.py b/apps/api/src/opendirector_api/creation.py index c558477f..4e2390be 100644 --- a/apps/api/src/opendirector_api/creation.py +++ b/apps/api/src/opendirector_api/creation.py @@ -56,6 +56,8 @@ async def execute(project_id: UUID, value: Command, x_opendirector_session: UUID if value.projectId != project_id: raise ProjectError('命令不属于当前工程。') session = str(x_opendirector_session) if x_opendirector_session else None + if value.type == 'artifact.publish': + raise ProjectError('产物发布需要服务端执行上下文,不能通过路径直接访问本机文件。', 403) if value.type == 'job.checkpoint': raise ProjectError('任务执行事实只能由后台写入。', 403) if value.type in {'generation.submit', 'job.cancel', 'job.recover'}: diff --git a/packages/core/src/opendirector/application/creation_artifacts.py b/packages/core/src/opendirector/application/creation_artifacts.py new file mode 100644 index 00000000..e6902eef --- /dev/null +++ b/packages/core/src/opendirector/application/creation_artifacts.py @@ -0,0 +1,66 @@ +"""Publish execution files through the shared asset staging and commit services.""" +from contextlib import ExitStack +import os +from pathlib import Path +import stat + +from opendirector.domain.creation.project import ProjectSnapshot +from opendirector.domain.projects import ProjectError +from opendirector.infrastructure.creation_files import CHUNK_BYTES +from opendirector.infrastructure.creation_uploads import asset_name + + +def publish_artifact(assets, command, *, execution_directory: Path, session, provenance, source='agent'): + """The host supplies the execution directory; command payloads cannot choose it.""" + project_id, request = command.projectId, command.payload + projects = assets.projects + replay = projects.replay(command) + if replay is not None: + return replay + with projects.local.transaction() as db: + projects._recover() + directory, _, records = projects._load(db, project_id) + projects._lease(db, project_id, directory, session) + snapshot = records[0].snapshot + for reference in request.sources: + assets._reference(snapshot, reference) + if os.open not in os.supports_dir_fd or not hasattr(os, 'O_NOFOLLOW'): + raise ProjectError('当前平台不支持安全的执行产物文件读取。', 501) + work = execution_directory.resolve() + candidate = Path(request.path) + candidate = candidate if candidate.is_absolute() else work / candidate + # Normalize dot segments without following symlinks; the descriptor walk rejects links. + candidate = Path(os.path.abspath(candidate)) + if not candidate.is_relative_to(work) or candidate == work: + raise ProjectError('只能发布本轮执行目录中的文件。', 403) + name = asset_name(request.name) + key = command.commandId + with ExitStack() as stack: + descriptor = os.open(work, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + stack.callback(os.close, descriptor) + parts = candidate.relative_to(work).parts + for part in parts[:-1]: + descriptor = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor) + stack.callback(os.close, descriptor) + file_descriptor = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor) + stack.callback(os.close, file_descriptor) + facts = os.fstat(file_descriptor) + if not stat.S_ISREG(facts.st_mode) or facts.st_size == 0: + raise ProjectError('发布产物必须是非空普通文件。') + stream = stack.enter_context(os.fdopen(os.dup(file_descriptor), 'rb')) + writer = assets.uploads.begin(project_id, key, session, name, expected_kind=request.expectedKind) + try: + while chunk := stream.read(CHUNK_BYTES): + writer.write(chunk) + after = os.fstat(stream.fileno()) + if (facts.st_size, facts.st_mtime_ns, facts.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns): + raise ProjectError('发布期间文件发生变化,请等待文件写入完成后重试。', 409) + writer.finish() + finally: + writer.abort() + def attach(before, asset): + for reference in request.sources: + assets._reference(before, reference) + return ProjectSnapshot.model_validate({**before.model_dump(), 'assets': [*before.assets, asset]}) + return assets.register(command, session=session, source=source, provenance=provenance.model_copy(update={'sources': request.sources}), + apply=attach, upload_handle=key) diff --git a/packages/core/src/opendirector/application/creation_assets.py b/packages/core/src/opendirector/application/creation_assets.py index 00e18392..cd164742 100644 --- a/packages/core/src/opendirector/application/creation_assets.py +++ b/packages/core/src/opendirector/application/creation_assets.py @@ -69,7 +69,7 @@ def _reference(cls, snapshot, reference): raise ContractError('reference_invalid', 'Asset reference differs from its content', asset.id) return asset - def register(self, command, *, session=None, source='ui', provenance=None, apply=None): + def register(self, command, *, session=None, source='ui', provenance=None, apply=None, upload_handle=None): """Trusted services may supply provenance and atomically update jobs/editors. HTTP never accepts these extra arguments. Generated/edited outputs @@ -79,9 +79,9 @@ def register(self, command, *, session=None, source='ui', provenance=None, apply if replay is not None: return replay payload = command.payload - if command.type != 'asset.import': + if command.type != 'asset.import' and not (command.type == 'artifact.publish' and upload_handle is not None): raise ProjectError('文件登记需要素材导入命令。') - handle = payload.uploadHandle + handle = payload.uploadHandle if command.type == 'asset.import' else upload_handle with self.uploads.lock(command.projectId, handle): _, owner, record = self.uploads.ready(command.projectId, handle, session) asset = Asset(id=upload_asset_id(command.projectId, handle), projectId=command.projectId, @@ -89,7 +89,7 @@ def register(self, command, *, session=None, source='ui', provenance=None, apply path=f'assets/{upload_asset_id(command.projectId, handle)}', metadata=record.metadata, provenance=provenance or AssetProvenance(source='upload')) def register(before): - if payload.expectedKind != record.kind: + if payload.expectedKind is not None and payload.expectedKind != record.kind: raise ContractError('invalid_input', 'Uploaded media kind differs from the command') existing = next((item for item in before.assets if item.id == asset.id), None) if existing: diff --git a/packages/core/src/opendirector/domain/creation/commands.py b/packages/core/src/opendirector/domain/creation/commands.py index df3719ed..fa303eff 100644 --- a/packages/core/src/opendirector/domain/creation/commands.py +++ b/packages/core/src/opendirector/domain/creation/commands.py @@ -25,6 +25,14 @@ class AssetImport(Contract): expectedKind: MediaKind +class ArtifactPublish(Contract): + """Publish a file from a server-owned execution context without generating media.""" + path: str = Field(min_length=1, max_length=4096) + name: str = Field(min_length=1, max_length=255) + expectedKind: MediaKind | None = None + sources: tuple[AssetRef, ...] = Field(default=(), max_length=100) + + class AssetWriteText(Contract): name: str = Field(min_length=1, max_length=255) text: str = Field(max_length=1024 * 1024) @@ -277,6 +285,7 @@ def scope_and_versions(self): ('ProjectTrashCommand', 'project.trash', EmptyPayload), ('ProjectRestoreCommand', 'project.restore', EmptyPayload), ('AssetImportCommand', 'asset.import', AssetImport), + ('ArtifactPublishCommand', 'artifact.publish', ArtifactPublish), ('AssetWriteTextCommand', 'asset.writeText', AssetWriteText), ('AssetMetadataCommand', 'asset.updateMetadata', AssetMetadataUpdate), ('AssetDeleteCommand', 'asset.delete', AssetDelete), diff --git a/src/contracts/creation.schema.json b/src/contracts/creation.schema.json index 3d16db19..86d291e7 100644 --- a/src/contracts/creation.schema.json +++ b/src/contracts/creation.schema.json @@ -4,6 +4,110 @@ "projectFormatVersion": 2, "input": { "$defs": { + "ArtifactPublish": { + "additionalProperties": false, + "description": "Publish a file from a server-owned execution context without generating media.", + "properties": { + "path": { + "maxLength": 4096, + "minLength": 1, + "title": "Path", + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "expectedKind": { + "anyOf": [ + { + "enum": [ + "text", + "image", + "video", + "audio", + "file" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expectedkind" + }, + "sources": { + "default": [], + "items": { + "$ref": "#/$defs/AssetRef" + }, + "maxItems": 100, + "title": "Sources", + "type": "array" + } + }, + "required": [ + "path", + "name" + ], + "title": "ArtifactPublish", + "type": "object" + }, + "ArtifactPublishCommand": { + "additionalProperties": false, + "properties": { + "commandId": { + "format": "uuid", + "title": "Commandid", + "type": "string" + }, + "projectId": { + "format": "uuid", + "title": "Projectid", + "type": "string" + }, + "type": { + "const": "artifact.publish", + "title": "Type", + "type": "string" + }, + "expectedVersions": { + "default": [], + "items": { + "$ref": "#/$defs/ObjectVersion" + }, + "title": "Expectedversions", + "type": "array" + }, + "expectedPreview": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expectedpreview" + }, + "payload": { + "$ref": "#/$defs/ArtifactPublish" + } + }, + "required": [ + "commandId", + "projectId", + "type", + "payload" + ], + "title": "ArtifactPublishCommand", + "type": "object" + }, "Asset": { "additionalProperties": false, "properties": { @@ -2734,6 +2838,7 @@ "command": { "discriminator": { "mapping": { + "artifact.publish": "#/$defs/ArtifactPublishCommand", "asset.delete": "#/$defs/AssetDeleteCommand", "asset.import": "#/$defs/AssetImportCommand", "asset.updateMetadata": "#/$defs/AssetMetadataCommand", @@ -2791,6 +2896,9 @@ { "$ref": "#/$defs/AssetImportCommand" }, + { + "$ref": "#/$defs/ArtifactPublishCommand" + }, { "$ref": "#/$defs/AssetWriteTextCommand" }, @@ -11564,6 +11672,7 @@ "command": { "discriminator": { "mapping": { + "artifact.publish": "#/$defs/ArtifactPublishCommand", "asset.delete": "#/$defs/AssetDeleteCommand", "asset.import": "#/$defs/AssetImportCommand", "asset.updateMetadata": "#/$defs/AssetMetadataCommand", @@ -11621,6 +11730,9 @@ { "$ref": "#/$defs/AssetImportCommand" }, + { + "$ref": "#/$defs/ArtifactPublishCommand" + }, { "$ref": "#/$defs/AssetWriteTextCommand" }, @@ -11827,6 +11939,114 @@ }, "output": { "$defs": { + "ArtifactPublish": { + "additionalProperties": false, + "description": "Publish a file from a server-owned execution context without generating media.", + "properties": { + "path": { + "maxLength": 4096, + "minLength": 1, + "title": "Path", + "type": "string" + }, + "name": { + "maxLength": 255, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "expectedKind": { + "anyOf": [ + { + "enum": [ + "text", + "image", + "video", + "audio", + "file" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expectedkind" + }, + "sources": { + "default": [], + "items": { + "$ref": "#/$defs/AssetRef" + }, + "maxItems": 100, + "title": "Sources", + "type": "array" + } + }, + "required": [ + "path", + "name", + "expectedKind", + "sources" + ], + "title": "ArtifactPublish", + "type": "object" + }, + "ArtifactPublishCommand": { + "additionalProperties": false, + "properties": { + "commandId": { + "format": "uuid", + "title": "Commandid", + "type": "string" + }, + "projectId": { + "format": "uuid", + "title": "Projectid", + "type": "string" + }, + "type": { + "const": "artifact.publish", + "title": "Type", + "type": "string" + }, + "expectedVersions": { + "default": [], + "items": { + "$ref": "#/$defs/ObjectVersion" + }, + "title": "Expectedversions", + "type": "array" + }, + "expectedPreview": { + "anyOf": [ + { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expectedpreview" + }, + "payload": { + "$ref": "#/$defs/ArtifactPublish" + } + }, + "required": [ + "commandId", + "projectId", + "type", + "expectedVersions", + "expectedPreview", + "payload" + ], + "title": "ArtifactPublishCommand", + "type": "object" + }, "Asset": { "additionalProperties": false, "properties": { @@ -14655,6 +14875,7 @@ "command": { "discriminator": { "mapping": { + "artifact.publish": "#/$defs/ArtifactPublishCommand", "asset.delete": "#/$defs/AssetDeleteCommand", "asset.import": "#/$defs/AssetImportCommand", "asset.updateMetadata": "#/$defs/AssetMetadataCommand", @@ -14712,6 +14933,9 @@ { "$ref": "#/$defs/AssetImportCommand" }, + { + "$ref": "#/$defs/ArtifactPublishCommand" + }, { "$ref": "#/$defs/AssetWriteTextCommand" }, @@ -23921,6 +24145,7 @@ "command": { "discriminator": { "mapping": { + "artifact.publish": "#/$defs/ArtifactPublishCommand", "asset.delete": "#/$defs/AssetDeleteCommand", "asset.import": "#/$defs/AssetImportCommand", "asset.updateMetadata": "#/$defs/AssetMetadataCommand", @@ -23978,6 +24203,9 @@ { "$ref": "#/$defs/AssetImportCommand" }, + { + "$ref": "#/$defs/ArtifactPublishCommand" + }, { "$ref": "#/$defs/AssetWriteTextCommand" }, diff --git a/src/contracts/generated.ts b/src/contracts/generated.ts index 8dbcd098..6ce3f22e 100644 --- a/src/contracts/generated.ts +++ b/src/contracts/generated.ts @@ -2,6 +2,18 @@ // Names without Input describe normalized JSON output; Input permits declared defaults. // Runtime constraints and cross-object validation are enforced by the Python contracts. +export interface ArtifactPublishInput extends ContractInput { + readonly path: string; + readonly name: string; + readonly expectedKind?: "text" | "image" | "video" | "audio" | "file" | null; + readonly sources?: ReadonlyArray; +} + +export interface ArtifactPublishCommandInput extends CommandBaseInput { + readonly type: "artifact.publish"; + readonly payload: ArtifactPublishInput; +} + export interface AssetInput extends ProjectEntityInput { readonly kind: "text" | "image" | "video" | "audio" | "file"; readonly name: string; @@ -334,7 +346,7 @@ export interface CreationContractsInput extends ContractInput { readonly generationSpec: TextGenerationSpecInput | ImageGenerationSpecInput | VideoGenerationSpecInput | AudioGenerationSpecInput; readonly reference: PinnedReferenceInput | FollowingReferenceInput; readonly editorDocument: TimelineDocumentInput | SubjectDocumentInput | LayerDocumentInput | ImageCropDocumentInput | StackDocumentInput | ToolDocumentInput | DirectorDocumentInput | MediaEditDocumentInput; - readonly command: ProjectCreateCommandInput | ProjectRenameCommandInput | ProjectTrashCommandInput | ProjectRestoreCommandInput | AssetImportCommandInput | AssetWriteTextCommandInput | AssetMetadataCommandInput | AssetDeleteCommandInput | CanvasShowCommandInput | CanvasMoveCommandInput | CanvasUpdateCommandInput | CanvasHideCommandInput | CanvasMaterializeCommandInput | CanvasApplyEditCommandInput | CanvasPublishMediaCommandInput | TimelinePublishRenderCommandInput | NodeVersionApplyCommandInput | CanvasConfigureCommandInput | CanvasEditorAttachCommandInput | CanvasEditorDetachCommandInput | SpecCreateCommandInput | SpecUpdateCommandInput | SpecRestoreCommandInput | SpecPromptCommandInput | ReferenceAddCommandInput | ReferenceUpdateCommandInput | ReferenceRestoreCommandInput | ReferenceRemoveCommandInput | GenerationSubmitCommandInput | JobCancelCommandInput | JobRecoverCommandInput | JobCheckpointCommandInput | ResultSelectCommandInput | EditorCreateCommandInput | EditorSaveCommandInput | HistoryUndoCommandInput | HistoryRedoCommandInput | BatchCommandInput; + readonly command: ProjectCreateCommandInput | ProjectRenameCommandInput | ProjectTrashCommandInput | ProjectRestoreCommandInput | AssetImportCommandInput | ArtifactPublishCommandInput | AssetWriteTextCommandInput | AssetMetadataCommandInput | AssetDeleteCommandInput | CanvasShowCommandInput | CanvasMoveCommandInput | CanvasUpdateCommandInput | CanvasHideCommandInput | CanvasMaterializeCommandInput | CanvasApplyEditCommandInput | CanvasPublishMediaCommandInput | TimelinePublishRenderCommandInput | NodeVersionApplyCommandInput | CanvasConfigureCommandInput | CanvasEditorAttachCommandInput | CanvasEditorDetachCommandInput | SpecCreateCommandInput | SpecUpdateCommandInput | SpecRestoreCommandInput | SpecPromptCommandInput | ReferenceAddCommandInput | ReferenceUpdateCommandInput | ReferenceRestoreCommandInput | ReferenceRemoveCommandInput | GenerationSubmitCommandInput | JobCancelCommandInput | JobRecoverCommandInput | JobCheckpointCommandInput | ResultSelectCommandInput | EditorCreateCommandInput | EditorSaveCommandInput | HistoryUndoCommandInput | HistoryRedoCommandInput | BatchCommandInput; readonly receipt: CommandReceiptInput; readonly query: QueryInput; readonly event: ProjectEventInput; @@ -1345,7 +1357,7 @@ export type ReferenceInput = PinnedReferenceInput | FollowingReferenceInput; export type EditorDocumentInput = TimelineDocumentInput | SubjectDocumentInput | LayerDocumentInput | ImageCropDocumentInput | StackDocumentInput | ToolDocumentInput | DirectorDocumentInput | MediaEditDocumentInput; -export type CommandInput = ProjectCreateCommandInput | ProjectRenameCommandInput | ProjectTrashCommandInput | ProjectRestoreCommandInput | AssetImportCommandInput | AssetWriteTextCommandInput | AssetMetadataCommandInput | AssetDeleteCommandInput | CanvasShowCommandInput | CanvasMoveCommandInput | CanvasUpdateCommandInput | CanvasHideCommandInput | CanvasMaterializeCommandInput | CanvasApplyEditCommandInput | CanvasPublishMediaCommandInput | TimelinePublishRenderCommandInput | NodeVersionApplyCommandInput | CanvasConfigureCommandInput | CanvasEditorAttachCommandInput | CanvasEditorDetachCommandInput | SpecCreateCommandInput | SpecUpdateCommandInput | SpecRestoreCommandInput | SpecPromptCommandInput | ReferenceAddCommandInput | ReferenceUpdateCommandInput | ReferenceRestoreCommandInput | ReferenceRemoveCommandInput | GenerationSubmitCommandInput | JobCancelCommandInput | JobRecoverCommandInput | JobCheckpointCommandInput | ResultSelectCommandInput | EditorCreateCommandInput | EditorSaveCommandInput | HistoryUndoCommandInput | HistoryRedoCommandInput | BatchCommandInput; +export type CommandInput = ProjectCreateCommandInput | ProjectRenameCommandInput | ProjectTrashCommandInput | ProjectRestoreCommandInput | AssetImportCommandInput | ArtifactPublishCommandInput | AssetWriteTextCommandInput | AssetMetadataCommandInput | AssetDeleteCommandInput | CanvasShowCommandInput | CanvasMoveCommandInput | CanvasUpdateCommandInput | CanvasHideCommandInput | CanvasMaterializeCommandInput | CanvasApplyEditCommandInput | CanvasPublishMediaCommandInput | TimelinePublishRenderCommandInput | NodeVersionApplyCommandInput | CanvasConfigureCommandInput | CanvasEditorAttachCommandInput | CanvasEditorDetachCommandInput | SpecCreateCommandInput | SpecUpdateCommandInput | SpecRestoreCommandInput | SpecPromptCommandInput | ReferenceAddCommandInput | ReferenceUpdateCommandInput | ReferenceRestoreCommandInput | ReferenceRemoveCommandInput | GenerationSubmitCommandInput | JobCancelCommandInput | JobRecoverCommandInput | JobCheckpointCommandInput | ResultSelectCommandInput | EditorCreateCommandInput | EditorSaveCommandInput | HistoryUndoCommandInput | HistoryRedoCommandInput | BatchCommandInput; export type ExecutionParametersInput = TextExecutionInput | ImageExecutionInput | VideoExecutionInput | AudioExecutionInput; @@ -1359,6 +1371,18 @@ export type ModelKindInput = "agent" | "image" | "video" | "audio" | "vision"; export type VideoModeInput = "first_frame" | "reference" | "continuation"; +export interface ArtifactPublish extends Contract { + readonly path: string; + readonly name: string; + readonly expectedKind: "text" | "image" | "video" | "audio" | "file" | null; + readonly sources: ReadonlyArray; +} + +export interface ArtifactPublishCommand extends CommandBase { + readonly type: "artifact.publish"; + readonly payload: ArtifactPublish; +} + export interface Asset extends ProjectEntity { readonly kind: "text" | "image" | "video" | "audio" | "file"; readonly name: string; @@ -1691,7 +1715,7 @@ export interface CreationContracts extends Contract { readonly generationSpec: TextGenerationSpec | ImageGenerationSpec | VideoGenerationSpec | AudioGenerationSpec; readonly reference: PinnedReference | FollowingReference; readonly editorDocument: TimelineDocument | SubjectDocument | LayerDocument | ImageCropDocument | StackDocument | ToolDocument | DirectorDocument | MediaEditDocument; - readonly command: ProjectCreateCommand | ProjectRenameCommand | ProjectTrashCommand | ProjectRestoreCommand | AssetImportCommand | AssetWriteTextCommand | AssetMetadataCommand | AssetDeleteCommand | CanvasShowCommand | CanvasMoveCommand | CanvasUpdateCommand | CanvasHideCommand | CanvasMaterializeCommand | CanvasApplyEditCommand | CanvasPublishMediaCommand | TimelinePublishRenderCommand | NodeVersionApplyCommand | CanvasConfigureCommand | CanvasEditorAttachCommand | CanvasEditorDetachCommand | SpecCreateCommand | SpecUpdateCommand | SpecRestoreCommand | SpecPromptCommand | ReferenceAddCommand | ReferenceUpdateCommand | ReferenceRestoreCommand | ReferenceRemoveCommand | GenerationSubmitCommand | JobCancelCommand | JobRecoverCommand | JobCheckpointCommand | ResultSelectCommand | EditorCreateCommand | EditorSaveCommand | HistoryUndoCommand | HistoryRedoCommand | BatchCommand; + readonly command: ProjectCreateCommand | ProjectRenameCommand | ProjectTrashCommand | ProjectRestoreCommand | AssetImportCommand | ArtifactPublishCommand | AssetWriteTextCommand | AssetMetadataCommand | AssetDeleteCommand | CanvasShowCommand | CanvasMoveCommand | CanvasUpdateCommand | CanvasHideCommand | CanvasMaterializeCommand | CanvasApplyEditCommand | CanvasPublishMediaCommand | TimelinePublishRenderCommand | NodeVersionApplyCommand | CanvasConfigureCommand | CanvasEditorAttachCommand | CanvasEditorDetachCommand | SpecCreateCommand | SpecUpdateCommand | SpecRestoreCommand | SpecPromptCommand | ReferenceAddCommand | ReferenceUpdateCommand | ReferenceRestoreCommand | ReferenceRemoveCommand | GenerationSubmitCommand | JobCancelCommand | JobRecoverCommand | JobCheckpointCommand | ResultSelectCommand | EditorCreateCommand | EditorSaveCommand | HistoryUndoCommand | HistoryRedoCommand | BatchCommand; readonly receipt: CommandReceipt; readonly query: Query; readonly event: ProjectEvent; @@ -2701,7 +2725,7 @@ export type Reference = PinnedReference | FollowingReference; export type EditorDocument = TimelineDocument | SubjectDocument | LayerDocument | ImageCropDocument | StackDocument | ToolDocument | DirectorDocument | MediaEditDocument; -export type Command = ProjectCreateCommand | ProjectRenameCommand | ProjectTrashCommand | ProjectRestoreCommand | AssetImportCommand | AssetWriteTextCommand | AssetMetadataCommand | AssetDeleteCommand | CanvasShowCommand | CanvasMoveCommand | CanvasUpdateCommand | CanvasHideCommand | CanvasMaterializeCommand | CanvasApplyEditCommand | CanvasPublishMediaCommand | TimelinePublishRenderCommand | NodeVersionApplyCommand | CanvasConfigureCommand | CanvasEditorAttachCommand | CanvasEditorDetachCommand | SpecCreateCommand | SpecUpdateCommand | SpecRestoreCommand | SpecPromptCommand | ReferenceAddCommand | ReferenceUpdateCommand | ReferenceRestoreCommand | ReferenceRemoveCommand | GenerationSubmitCommand | JobCancelCommand | JobRecoverCommand | JobCheckpointCommand | ResultSelectCommand | EditorCreateCommand | EditorSaveCommand | HistoryUndoCommand | HistoryRedoCommand | BatchCommand; +export type Command = ProjectCreateCommand | ProjectRenameCommand | ProjectTrashCommand | ProjectRestoreCommand | AssetImportCommand | ArtifactPublishCommand | AssetWriteTextCommand | AssetMetadataCommand | AssetDeleteCommand | CanvasShowCommand | CanvasMoveCommand | CanvasUpdateCommand | CanvasHideCommand | CanvasMaterializeCommand | CanvasApplyEditCommand | CanvasPublishMediaCommand | TimelinePublishRenderCommand | NodeVersionApplyCommand | CanvasConfigureCommand | CanvasEditorAttachCommand | CanvasEditorDetachCommand | SpecCreateCommand | SpecUpdateCommand | SpecRestoreCommand | SpecPromptCommand | ReferenceAddCommand | ReferenceUpdateCommand | ReferenceRestoreCommand | ReferenceRemoveCommand | GenerationSubmitCommand | JobCancelCommand | JobRecoverCommand | JobCheckpointCommand | ResultSelectCommand | EditorCreateCommand | EditorSaveCommand | HistoryUndoCommand | HistoryRedoCommand | BatchCommand; export type ExecutionParameters = TextExecution | ImageExecution | VideoExecution | AudioExecution; diff --git a/tests/test_creation_artifacts.py b/tests/test_creation_artifacts.py new file mode 100644 index 00000000..51efea4c --- /dev/null +++ b/tests/test_creation_artifacts.py @@ -0,0 +1,169 @@ +"""Execution files become real project assets through ordinary command receipts.""" +from pathlib import Path +import shutil +import subprocess +from uuid import uuid4 +import wave + +from fastapi.testclient import TestClient +from PIL import Image +import pytest + +from opendirector.application.creation_artifacts import publish_artifact +from opendirector.application.creation_assets import CreationAssets +from opendirector.application.creation_canvas import asset_ref +from opendirector.domain.creation.assets import AssetProvenance +from opendirector.domain.creation.commands import parse_command +from opendirector.domain.creation.models import ModelSnapshot +from opendirector.domain.projects import ProjectError +from opendirector.infrastructure.creation_projects import CreationProjects +from opendirector.infrastructure.local_projects import LocalProjects +from opendirector_api.main import create_app + + +@pytest.fixture +def publication(tmp_path): + projects = CreationProjects(LocalProjects(tmp_path)) + project_id, session = uuid4(), str(uuid4()) + projects.execute(parse_command({'type': 'project.create', 'projectId': project_id, + 'commandId': uuid4(), 'payload': {'name': 'Artifact tests'}})) + projects.open(project_id, session) + work = tmp_path / 'execution' + work.mkdir() + provenance = AssetProvenance(source='agent_written', agentRunId=uuid4(), + modelSnapshot=ModelSnapshot(modelConfigId=uuid4(), kind='agent', modelId='test-model', + url='http://localhost:1/v1', protocol='openai', connectionType='self_hosted')) + return CreationAssets(projects), project_id, session, work, provenance + + +def command(publication, path, *, command_id=None, name=None, sources=()): + _, project_id, _, _, _ = publication + return parse_command({'type': 'artifact.publish', 'projectId': project_id, + 'commandId': command_id or uuid4(), 'payload': {'path': path, + 'name': name or Path(path).name, 'sources': list(sources)}}) + + +def publish(publication, value): + assets, _, session, work, provenance = publication + return publish_artifact(assets, value, execution_directory=work, session=session, + provenance=provenance) + + +def test_publish_replay_conflict_and_command_history(publication): + assets, project_id, _, work, provenance = publication + output = work / 'plan.md' + output.write_text('Original plan') + value = command(publication, 'plan.md') + first = publish(publication, value) + assert first.status == 'applied' + asset = assets.get(project_id, first.changed[0].id) + assert asset.provenance == provenance + assert asset.metadata.mimeType == 'text/markdown' + assert output.read_text() == 'Original plan' + output.write_text('Changed after publication') + assert publish(publication, value) == first + output.unlink() + assert publish(publication, value) == first + with pytest.raises(ProjectError, match='不同输入'): + publish(publication, command(publication, 'other.md', command_id=value.commandId)) + with assets.projects.local.transaction() as db: + _, _, records = assets.projects._load(db, project_id) + record = next(record for record in records if record.command.commandId == value.commandId) + assert record.command.type == 'artifact.publish' + assert len(assets.projects.snapshot(project_id).assets) == 1 + + +@pytest.mark.parametrize('kind', ['image', 'audio', 'file']) +def test_shared_import_rules_and_source_identity(publication, kind): + assets, project_id, _, work, _ = publication + (work / 'source.md').write_text('source') + source_receipt = publish(publication, command(publication, 'source.md')) + source = assets.get(project_id, source_receipt.changed[0].id) + if kind == 'image': + path = work / 'frame.png' + Image.new('RGB', (13, 7), 'purple').save(path) + elif kind == 'audio': + path = work / 'sound.wav' + with wave.open(str(path), 'wb') as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(8000) + audio.writeframes(b'\0\0' * 800) + else: + path = work / 'report.html' + path.write_text('Report') + receipt = publish(publication, command(publication, str(path), sources=[asset_ref(source)])) + output = assets.get(project_id, receipt.changed[0].id) + assert output.kind == kind + assert output.provenance.sources == (asset_ref(source),) + with assets.open_media(project_id, output.id)[0] as stream: + assert stream.read() == path.read_bytes() + + +def test_file_boundary_links_empty_and_directory_are_rejected(publication, tmp_path): + assets, project_id, _, work, _ = publication + outside = tmp_path / 'outside.md' + outside.write_text('outside') + for path in [str(outside), '../outside.md']: + with pytest.raises(ProjectError, match='本轮执行目录'): + publish(publication, command(publication, path)) + (work / 'link.md').symlink_to(outside) + (work / 'link-directory').symlink_to(tmp_path, target_is_directory=True) + for path in ['link.md', 'link-directory/outside.md']: + with pytest.raises(OSError): + publish(publication, command(publication, path)) + (work / 'empty.md').touch() + (work / 'folder').mkdir() + for path in ['empty.md', 'folder']: + with pytest.raises(ProjectError, match='非空普通文件'): + publish(publication, command(publication, path)) + assert not assets.projects.snapshot(project_id).assets + + +def test_lost_commit_response_is_recoverable(publication, monkeypatch): + assets, project_id, _, work, _ = publication + (work / 'plan.md').write_text('plan') + value = command(publication, 'plan.md') + register = assets.register + def lost(*args, **kwargs): + register(*args, **kwargs) + raise OSError('lost response') + monkeypatch.setattr(assets, 'register', lost) + with pytest.raises(OSError, match='lost response'): + publish(publication, value) + monkeypatch.setattr(assets, 'register', register) + assert publish(publication, value).status == 'applied' + assert len(assets.projects.snapshot(project_id).assets) == 1 + + +def test_five_video_outputs_can_be_separate_canvas_items(publication): + if not shutil.which('ffmpeg') or not shutil.which('ffprobe'): + pytest.skip('requires ffmpeg and ffprobe') + assets, project_id, session, work, _ = publication + source = work / 'source.mp4' + subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=blue:s=32x32:r=24', + '-frames:v', '12', '-pix_fmt', 'yuv420p', str(source)], check=True) + for index in range(5): + name = f'segment-{index}.mp4' + shutil.copyfile(source, work / name) + published = publish(publication, command(publication, name)) + asset = assets.get(project_id, published.changed[0].id) + assert asset.metadata.frameRate == 24 + show = parse_command({'type': 'canvas.show', 'projectId': project_id, 'commandId': uuid4(), + 'payload': {'item': {'id': uuid4(), 'projectId': project_id, 'kind': 'video', 'title': name, + 'position': {'x': index * 360, 'y': 0}, + 'binding': {'role': 'material', 'objectRef': asset_ref(asset)}}}}) + assert assets.projects.execute(show, session=session).status == 'applied' + snapshot = assets.projects.snapshot(project_id) + assert len(snapshot.assets) == len(snapshot.canvasItems) == 5 + assert len({asset.id for asset in snapshot.assets}) == 5 + + +def test_untrusted_http_cannot_choose_execution_context(publication): + assets, project_id, session, _, _ = publication + with TestClient(create_app(assets.projects.local), headers={ + 'X-OpenDirector-Request': '1', 'X-OpenDirector-Session': session}) as client: + value = command(publication, '/etc/passwd', name='outside.txt') + response = client.post(f'/api/creation/projects/{project_id}/commands', json=value.model_dump(mode='json')) + assert response.status_code == 403 + assert not assets.projects.snapshot(project_id).assets