dirigent-core 0.23.4__tar.gz → 0.25.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/PKG-INFO +4 -4
  2. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/pyproject.toml +4 -4
  3. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/pyproject.toml.orig +4 -4
  4. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +118 -0
  5. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/blockdocs.py +43 -2
  6. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/config.py +30 -2
  7. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/documents.py +55 -17
  8. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/__init__.py +2 -0
  9. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/definition.py +176 -6
  10. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/executor.py +14 -1
  11. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/expansion.py +32 -3
  12. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/references.py +12 -3
  13. dirigent_core-0.25.0/src/dirigent_core/engine/replies.py +194 -0
  14. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/runs.py +117 -4
  15. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/services.py +8 -0
  16. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/state.py +154 -7
  17. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/examples.py +31 -0
  18. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/messages.py +215 -13
  19. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/models.py +88 -0
  20. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/pipelines.py +13 -1
  21. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/plugins.py +33 -2
  22. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/ratelimit.py +9 -4
  23. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/reporting.py +1 -1
  24. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/retention.py +15 -2
  25. dirigent_core-0.25.0/src/dirigent_core/sources/__init__.py +1 -0
  26. dirigent_core-0.25.0/src/dirigent_core/sources/endpoint.py +281 -0
  27. dirigent_core-0.25.0/src/dirigent_core/sources/entries.py +79 -0
  28. dirigent_core-0.25.0/src/dirigent_core/sources/intake.py +416 -0
  29. dirigent_core-0.25.0/src/dirigent_core/sources/kinds.py +22 -0
  30. dirigent_core-0.25.0/src/dirigent_core/sources/rows.py +407 -0
  31. dirigent_core-0.25.0/src/dirigent_core/sources/rules.py +310 -0
  32. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/telemetry.py +10 -2
  33. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/triggers/watches.py +2 -2
  34. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/wakeups.py +7 -2
  35. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/LICENSE +0 -0
  36. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/README.md +0 -0
  37. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/__init__.py +0 -0
  38. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/alembic/env.py +0 -0
  39. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/alembic/script.py.mako +0 -0
  40. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/alerting.py +0 -0
  41. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/artifacts.py +0 -0
  42. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/auth.py +0 -0
  43. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/configdocs.py +0 -0
  44. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/database.py +0 -0
  45. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/directory.py +0 -0
  46. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/documentschema.py +0 -0
  47. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/claim.py +0 -0
  48. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/context.py +0 -0
  49. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/failure.py +0 -0
  50. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/engine/recovery.py +0 -0
  51. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/errors.py +0 -0
  52. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/ids.py +0 -0
  53. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/logging.py +0 -0
  54. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/migrations.py +0 -0
  55. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/protocol.py +0 -0
  56. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/py.typed +0 -0
  57. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/registry.py +0 -0
  58. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/scheduler.py +0 -0
  59. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/schemas.py +0 -0
  60. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/secrets.py +0 -0
  61. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/storage.py +0 -0
  62. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/trigger_documents.py +0 -0
  63. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/triggers/__init__.py +0 -0
  64. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/triggers/backfill.py +0 -0
  65. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/triggers/materialize.py +0 -0
  66. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/triggers/schedules.py +0 -0
  67. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/triggers/webhooks.py +0 -0
  68. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/types.py +0 -0
  69. {dirigent_core-0.23.4 → dirigent_core-0.25.0}/src/dirigent_core/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-core
3
- Version: 0.23.4
3
+ Version: 0.25.0
4
4
  Summary: Dirigent engine core: schema, configuration, and plugin host.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -12,9 +12,9 @@ Requires-Dist: argon2-cffi>=25.1.0
12
12
  Requires-Dist: asyncpg>=0.31.0
13
13
  Requires-Dist: cronsim>=2.7
14
14
  Requires-Dist: cryptography>=46.0.5
15
- Requires-Dist: dirigent-client==0.23.4
16
- Requires-Dist: dirigent-common==0.23.4
17
- Requires-Dist: dirigent-plugin==0.23.4
15
+ Requires-Dist: dirigent-client==0.25.0
16
+ Requires-Dist: dirigent-common==0.25.0
17
+ Requires-Dist: dirigent-plugin==0.25.0
18
18
  Requires-Dist: jsonschema>=4.26.0
19
19
  Requires-Dist: opentelemetry-api>=1.44.0
20
20
  Requires-Dist: pydantic-settings>=2.15.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.23.4"
3
+ version = "0.25.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.23.4",
21
- "dirigent-common==0.23.4",
22
- "dirigent-plugin==0.23.4",
20
+ "dirigent-client==0.25.0",
21
+ "dirigent-common==0.25.0",
22
+ "dirigent-plugin==0.25.0",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.23.4"
3
+ version = "0.25.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.23.4",
21
- "dirigent-common==0.23.4",
22
- "dirigent-plugin==0.23.4",
20
+ "dirigent-client==0.25.0",
21
+ "dirigent-common==0.25.0",
22
+ "dirigent-plugin==0.25.0",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -545,6 +545,71 @@ def upgrade() -> None:
545
545
  batch_op.create_index(batch_op.f("ix_watches_pipeline_id"), ["pipeline_id"], unique=False)
546
546
  batch_op.create_index(batch_op.f("ix_watches_trigger_document_id"), ["trigger_document_id"], unique=False)
547
547
 
548
+ op.create_table(
549
+ "sources",
550
+ sa.Column("id", sa.Uuid(), nullable=False),
551
+ sa.Column("pipeline_id", sa.Uuid(), nullable=False),
552
+ sa.Column("code", sa.String(length=200), nullable=False),
553
+ sa.Column("name", sa.String(length=200), nullable=True),
554
+ sa.Column("description", sa.Text(), nullable=True),
555
+ sa.Column("kind", sa.String(length=200), nullable=False),
556
+ sa.Column("config", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=False),
557
+ sa.Column("token_hash", sa.String(length=128), nullable=True),
558
+ sa.Column("token_prefix", sa.String(length=16), nullable=True),
559
+ sa.Column("paused", sa.Boolean(), server_default=sa.false(), nullable=False),
560
+ sa.Column("accepted", sa.Integer(), server_default=sa.text("0"), nullable=False),
561
+ sa.Column("refused", sa.Integer(), server_default=sa.text("0"), nullable=False),
562
+ sa.Column("last_input_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
563
+ sa.Column(
564
+ "created_at",
565
+ dirigent_core.types.UtcDateTime(timezone=True),
566
+ server_default=sa.func.now(),
567
+ nullable=False,
568
+ ),
569
+ sa.Column(
570
+ "updated_at",
571
+ dirigent_core.types.UtcDateTime(timezone=True),
572
+ server_default=sa.func.now(),
573
+ nullable=False,
574
+ ),
575
+ sa.ForeignKeyConstraint(
576
+ ["pipeline_id"], ["pipelines.id"], name=op.f("fk_sources_pipeline_id_pipelines"), ondelete="CASCADE"
577
+ ),
578
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_sources")),
579
+ sa.UniqueConstraint("pipeline_id", "code", name=op.f("uq_sources_pipeline_id_code")),
580
+ sa.UniqueConstraint("token_hash", name=op.f("uq_sources_token_hash")),
581
+ )
582
+ with op.batch_alter_table("sources", schema=None) as batch_op:
583
+ batch_op.create_index(batch_op.f("ix_sources_pipeline_id"), ["pipeline_id"], unique=False)
584
+
585
+ op.create_table(
586
+ "source_routes",
587
+ sa.Column("id", sa.Uuid(), nullable=False),
588
+ sa.Column("source_id", sa.Uuid(), nullable=False),
589
+ sa.Column("method", sa.String(length=16), nullable=False),
590
+ sa.Column("template", sa.String(length=1000), nullable=False),
591
+ sa.Column("normalised", sa.String(length=1000), nullable=False),
592
+ sa.Column(
593
+ "created_at",
594
+ dirigent_core.types.UtcDateTime(timezone=True),
595
+ server_default=sa.func.now(),
596
+ nullable=False,
597
+ ),
598
+ sa.Column(
599
+ "updated_at",
600
+ dirigent_core.types.UtcDateTime(timezone=True),
601
+ server_default=sa.func.now(),
602
+ nullable=False,
603
+ ),
604
+ sa.ForeignKeyConstraint(
605
+ ["source_id"], ["sources.id"], name=op.f("fk_source_routes_source_id_sources"), ondelete="CASCADE"
606
+ ),
607
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_source_routes")),
608
+ sa.UniqueConstraint("method", "normalised", name=op.f("uq_source_routes_method_normalised")),
609
+ )
610
+ with op.batch_alter_table("source_routes", schema=None) as batch_op:
611
+ batch_op.create_index(batch_op.f("ix_source_routes_source_id"), ["source_id"], unique=False)
612
+
548
613
  op.create_table(
549
614
  "runs",
550
615
  sa.Column("id", sa.Uuid(), nullable=False),
@@ -591,6 +656,7 @@ def upgrade() -> None:
591
656
  ),
592
657
  sa.Column("triggered_by_id", sa.Uuid(), nullable=True),
593
658
  sa.Column("triggered_by_label", sa.String(length=200), nullable=True),
659
+ sa.Column("source_step", sa.String(length=200), nullable=True),
594
660
  sa.Column("traceparent", sa.String(length=64), nullable=True),
595
661
  sa.Column("error", sa.Text(), nullable=True),
596
662
  sa.Column("error_code", sa.String(length=128), nullable=True),
@@ -639,6 +705,7 @@ def upgrade() -> None:
639
705
  "ix_runs_pipeline_id_status_created_at", ["pipeline_id", "status", "created_at"], unique=False
640
706
  )
641
707
  batch_op.create_index(batch_op.f("ix_runs_status"), ["status"], unique=False)
708
+ batch_op.create_index("ix_runs_triggered_by_id_status", ["triggered_by_id", "status"], unique=False)
642
709
 
643
710
  op.create_table(
644
711
  "notifications",
@@ -838,6 +905,43 @@ def upgrade() -> None:
838
905
  batch_op.create_index("ix_webhook_deliveries_created_at", ["created_at"], unique=False)
839
906
  batch_op.create_index("ix_webhook_deliveries_webhook_id_id", ["webhook_id", "id"], unique=False)
840
907
 
908
+ op.create_table(
909
+ "source_inputs",
910
+ sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), autoincrement=True, nullable=False),
911
+ sa.Column("source_id", sa.Uuid(), nullable=False),
912
+ sa.Column("run_id", sa.Uuid(), nullable=True),
913
+ sa.Column(
914
+ "outcome",
915
+ sa.Enum("accepted", "refused", name="input_outcome", native_enum=False, length=32),
916
+ nullable=False,
917
+ ),
918
+ sa.Column("status", sa.Integer(), nullable=False),
919
+ sa.Column("code", sa.String(length=128), nullable=True),
920
+ sa.Column("reason", sa.Text(), nullable=True),
921
+ sa.Column("params", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True),
922
+ sa.Column("method", sa.String(length=16), nullable=False),
923
+ sa.Column("path", sa.String(length=2000), nullable=False),
924
+ sa.Column("body_bytes", sa.Integer(), server_default=sa.text("0"), nullable=False),
925
+ sa.Column("duration_ms", sa.Integer(), server_default=sa.text("0"), nullable=False),
926
+ sa.Column("caller", sa.String(length=64), nullable=True),
927
+ sa.Column(
928
+ "created_at",
929
+ dirigent_core.types.UtcDateTime(timezone=True),
930
+ server_default=sa.func.now(),
931
+ nullable=False,
932
+ ),
933
+ sa.ForeignKeyConstraint(
934
+ ["run_id"], ["runs.id"], name=op.f("fk_source_inputs_run_id_runs"), ondelete="SET NULL"
935
+ ),
936
+ sa.ForeignKeyConstraint(
937
+ ["source_id"], ["sources.id"], name=op.f("fk_source_inputs_source_id_sources"), ondelete="CASCADE"
938
+ ),
939
+ sa.PrimaryKeyConstraint("id", name=op.f("pk_source_inputs")),
940
+ )
941
+ with op.batch_alter_table("source_inputs", schema=None) as batch_op:
942
+ batch_op.create_index("ix_source_inputs_created_at", ["created_at"], unique=False)
943
+ batch_op.create_index("ix_source_inputs_source_id_id", ["source_id", "id"], unique=False)
944
+
841
945
  op.create_table(
842
946
  "step_attempts",
843
947
  sa.Column("id", sa.Uuid(), nullable=False),
@@ -1034,6 +1138,11 @@ def downgrade() -> None:
1034
1138
  batch_op.drop_index("ix_webhook_deliveries_created_at")
1035
1139
 
1036
1140
  op.drop_table("webhook_deliveries")
1141
+ with op.batch_alter_table("source_inputs", schema=None) as batch_op:
1142
+ batch_op.drop_index("ix_source_inputs_source_id_id")
1143
+ batch_op.drop_index("ix_source_inputs_created_at")
1144
+
1145
+ op.drop_table("source_inputs")
1037
1146
  with op.batch_alter_table("schedule_firings", schema=None) as batch_op:
1038
1147
  batch_op.drop_index("ix_schedule_firings_schedule_id_id")
1039
1148
  batch_op.drop_index("ix_schedule_firings_created_at")
@@ -1052,6 +1161,7 @@ def downgrade() -> None:
1052
1161
 
1053
1162
  op.drop_table("notifications")
1054
1163
  with op.batch_alter_table("runs", schema=None) as batch_op:
1164
+ batch_op.drop_index("ix_runs_triggered_by_id_status")
1055
1165
  batch_op.drop_index(batch_op.f("ix_runs_status"))
1056
1166
  batch_op.drop_index("ix_runs_pipeline_id_status_created_at")
1057
1167
  batch_op.drop_index("ix_runs_finished_at")
@@ -1062,6 +1172,14 @@ def downgrade() -> None:
1062
1172
  batch_op.drop_index(batch_op.f("ix_watches_pipeline_id"))
1063
1173
 
1064
1174
  op.drop_table("watches")
1175
+ with op.batch_alter_table("source_routes", schema=None) as batch_op:
1176
+ batch_op.drop_index(batch_op.f("ix_source_routes_source_id"))
1177
+
1178
+ op.drop_table("source_routes")
1179
+ with op.batch_alter_table("sources", schema=None) as batch_op:
1180
+ batch_op.drop_index(batch_op.f("ix_sources_pipeline_id"))
1181
+
1182
+ op.drop_table("sources")
1065
1183
  with op.batch_alter_table("webhook_triggers", schema=None) as batch_op:
1066
1184
  batch_op.drop_index(batch_op.f("ix_webhook_triggers_trigger_document_id"))
1067
1185
  batch_op.drop_index(batch_op.f("ix_webhook_triggers_pipeline_id"))
@@ -7,7 +7,7 @@ catalog disagree.
7
7
  import json
8
8
  from typing import Final, cast
9
9
 
10
- from dirigent_client.schemas import BlockEntry, BlockKind, Catalog, SurfaceEntry
10
+ from dirigent_client.schemas import BlockEntry, BlockKind, Catalog, SourceEntry, SurfaceEntry
11
11
  from dirigent_common import JsonMap
12
12
  from dirigent_core.configdocs import first_paragraph, one_line
13
13
 
@@ -53,7 +53,8 @@ SURFACES_PREAMBLE: Final = """## Other surfaces
53
53
  Blocks are two of the six surfaces a plugin contributes to. Three of the other four -- storage
54
54
  backends, notifiers and connection kinds -- are listed here by id, since a document references
55
55
  them by code and never by id of any other kind. The sixth, a JSON Schema format checker, is in
56
- [JSON Schema](json-schema.md).
56
+ [JSON Schema](json-schema.md). Source kinds, which a source node names as ``source.kind``, are
57
+ listed after them; see [Source and reply](concepts.md#source-and-reply).
57
58
  """
58
59
 
59
60
  NO_DESCRIPTION: Final = "--"
@@ -73,6 +74,7 @@ def render(catalog: Catalog) -> str:
73
74
  parts.append(_surfaces("Storage schemes", "scheme", catalog.storage_schemes))
74
75
  parts.append(_surfaces("Notifiers", "id", catalog.notifiers))
75
76
  parts.append(_surfaces("Connection kinds", "id", catalog.connection_kinds))
77
+ parts.append(_sources(catalog.sources))
76
78
  return "\n".join(parts).rstrip() + "\n"
77
79
 
78
80
 
@@ -210,6 +212,45 @@ def _surfaces(title: str, label: str, entries: list[SurfaceEntry]) -> str:
210
212
  )
211
213
 
212
214
 
215
+ def _sources(entries: list[SourceEntry]) -> str:
216
+ """Render the source kinds as a short table, and the input each one hands on."""
217
+ if not entries:
218
+ return "### Source kinds\n\n_None installed._\n"
219
+ rows = [
220
+ f"| `{entry.kind}` | {entry.hosting} | {_yes(entry.replies)} | {entry.summary} | `{entry.plugin}` |"
221
+ for entry in entries
222
+ ]
223
+ lines = [
224
+ "### Source kinds",
225
+ "",
226
+ "| Kind | Hosting | Replies | Summary | Contributed by |",
227
+ "| --- | --- | --- | --- | --- |",
228
+ *rows,
229
+ "",
230
+ ]
231
+ for entry in entries:
232
+ lines.extend(
233
+ [
234
+ f"#### `{entry.kind}`",
235
+ "",
236
+ "**Config**",
237
+ "",
238
+ _schema_table(entry.config_schema),
239
+ "",
240
+ "**Input it hands on**",
241
+ "",
242
+ _schema_table(entry.output_schema),
243
+ "",
244
+ ]
245
+ )
246
+ return "\n".join(lines)
247
+
248
+
249
+ def _yes(flag: bool) -> str:
250
+ """Render a flag as a table cell reads it."""
251
+ return "yes" if flag else "no"
252
+
253
+
213
254
  def _anchor(block_id: str) -> str:
214
255
  """Render the anchor mkdocs gives a heading whose text is a backticked block id."""
215
256
  return block_id.replace(".", "")
@@ -290,6 +290,9 @@ class Settings(BaseSettings):
290
290
  retention_deliveries: Duration | None = None
291
291
  """How long a webhook delivery is kept."""
292
292
 
293
+ retention_inputs: Duration | None = None
294
+ """How long the record of an input that reached a source is kept."""
295
+
293
296
  retention_firings: Duration | None = None
294
297
  """How long a schedule firing is kept."""
295
298
 
@@ -355,6 +358,31 @@ class Settings(BaseSettings):
355
358
 
356
359
  A real webhook's own ``rate_limit_per_minute`` applies on top of this."""
357
360
 
361
+ endpoint_prefix: str = "/endpoints"
362
+ """Where endpoint sources are served, outside the versioned API.
363
+
364
+ A proxy in front of an instance publishes this prefix and ``/hooks`` and keeps
365
+ ``/api/v1`` inside, so a source's route is public without the API being so."""
366
+
367
+ source_max_body: Size = Field(default=1_048_576, ge=1)
368
+ """The largest body any endpoint reads, whatever its own ``max_body`` says; reading stops one byte past it."""
369
+
370
+ source_intake_rate_per_minute: int = Field(default=600, ge=1)
371
+ """How fast calls to one method and path are taken, before any source is looked up.
372
+
373
+ A source's own ``rate`` applies on top of this."""
374
+
375
+ source_reply_max: Duration = Field(default=timedelta(seconds=60), gt=timedelta(0))
376
+ """The longest a caller is held for a reply, whatever a reply node's ``timeout`` says."""
377
+
378
+ source_max_waiting: int = Field(default=256, ge=1)
379
+ """How many callers one server process holds open for their replies at once.
380
+
381
+ A call past it is answered 503 before its run is created."""
382
+
383
+ source_reply_poll: Duration = Field(default=timedelta(milliseconds=50), gt=timedelta(0))
384
+ """How often one server process reads the replies its held callers wait for, in one query."""
385
+
358
386
  login_rate_per_minute: int = Field(default=10, ge=1)
359
387
  """How many login attempts one address, and one username, get a minute.
360
388
 
@@ -383,10 +411,10 @@ class Settings(BaseSettings):
383
411
  """Accept a comma-separated ``key=value`` string for a mapping setting."""
384
412
  return parse_string_mapping(value)
385
413
 
386
- @field_validator("api_prefix")
414
+ @field_validator("api_prefix", "endpoint_prefix")
387
415
  @classmethod
388
416
  def _normalise_prefix(cls, value: str) -> str:
389
- """Keep the API prefix rooted and free of a trailing slash."""
417
+ """Keep a prefix rooted and free of a trailing slash."""
390
418
  prefix = "/" + value.strip("/")
391
419
  return prefix.rstrip("/") or "/"
392
420
 
@@ -45,8 +45,10 @@ from dirigent_core.messages import (
45
45
  NOT_A_MAPPING,
46
46
  NOT_YAML,
47
47
  PARAMS_SCHEMA_INVALID,
48
+ REFERENCE_ERROR_NOT_UPSTREAM,
48
49
  REFERENCE_GRID_IN_CONFIG,
49
50
  REFERENCE_MALFORMED_ARTIFACTS,
51
+ REFERENCE_MALFORMED_ERROR,
50
52
  REFERENCE_MALFORMED_ITEM,
51
53
  REFERENCE_MALFORMED_RUN,
52
54
  REFERENCE_MALFORMED_STEP,
@@ -357,8 +359,8 @@ def validate_against_catalog(
357
359
  definition: Document,
358
360
  catalog: Catalog,
359
361
  *,
360
- connections: Iterable[str] = (),
361
- pipelines: Iterable[str] = (),
362
+ connections: Iterable[str] | None = (),
363
+ pipelines: Iterable[str] | None = (),
362
364
  check_blocks: bool = True,
363
365
  unsafe_allowed: Iterable[str] | None = None,
364
366
  storage_schemes: Iterable[str] | None = None,
@@ -366,10 +368,13 @@ def validate_against_catalog(
366
368
  blocks: Mapping[str, Any] | None = None,
367
369
  format_checker: FormatChecker | None = None,
368
370
  target: TriggerTarget | None = None,
371
+ source_kinds: Mapping[str, Any] | None = None,
369
372
  ) -> list[ValidationIssue]:
370
373
  """Check a document against what this instance actually has, reporting everything at once.
371
374
 
372
- ``check_blocks`` is off on the offline path, where no catalog is available to check against.
375
+ ``check_blocks`` is off where no catalog is available to check against. ``connections`` and
376
+ ``pipelines`` are what the instance holds; None means the caller does not know, as on the
377
+ offline path, and then neither a step's connection nor the ones ``requires`` names is checked.
373
378
  ``unsafe_allowed`` and ``storage_schemes`` are what the instance permits and what its
374
379
  backends claim; left unset, neither is checked, because a caller that does not know them
375
380
  would otherwise refuse a document the instance would have run. ``blocks`` is the live
@@ -379,16 +384,23 @@ def validate_against_catalog(
379
384
  against; unset, they are checked against the base formats alone, which is all a caller
380
385
  holding no host can know. ``target`` is the pipeline a triggers document names, as the
381
386
  instance holds it now; on the offline path there is none to read, and only the clocks and
382
- the codes are checked.
387
+ the codes are checked. ``source_kinds`` is the source kinds the instance knows; unset, the
388
+ built-in ones.
383
389
  """
390
+ from dirigent_core.sources.kinds import built_in_kinds
391
+ from dirigent_core.sources.rules import source_issues
392
+
393
+ kinds = source_kinds if source_kinds is not None else built_in_kinds()
384
394
  checker = format_checker if format_checker is not None else base_format_checker()
385
395
  sensors = _sensors(catalog) if check_blocks else None
386
396
  if isinstance(definition, TriggersDefinition):
387
- return _triggers_document_issues(definition, checker, target, check_target=check_blocks, sensors=sensors)
397
+ return _triggers_document_issues(
398
+ definition, checker, target, check_target=check_blocks and pipelines is not None, sensors=sensors
399
+ )
388
400
  # A document that brings its own connections or schemas satisfies its own references to them.
389
- known = {name for name in connections} | set(definition.connections)
401
+ known = None if connections is None else {name for name in connections} | set(definition.connections)
390
402
  known_schemas = None if schemas is None else {name for name in schemas} | set(definition.schemas)
391
- held = {name for name in pipelines}
403
+ held = None if pipelines is None else {name for name in pipelines}
392
404
  issues: list[ValidationIssue] = []
393
405
  if check_blocks:
394
406
  issues.extend(
@@ -406,6 +418,7 @@ def validate_against_catalog(
406
418
  issues.extend(_carried_schema_issues(definition))
407
419
  issues.extend(_reference_issues(definition))
408
420
  issues.extend(_trigger_issues(definition, checker, check_params=not malformed_params, sensors=sensors))
421
+ issues.extend(source_issues(definition, kinds))
409
422
  if check_blocks:
410
423
  issues.extend(
411
424
  _step_block_issues(
@@ -607,18 +620,25 @@ def _triggers_document_issues(
607
620
  return [ValidationIssue.of(TARGET_PIPELINE_MISSING, location="pipeline", code=repr(definition.pipeline))]
608
621
  if not target.active:
609
622
  return [ValidationIssue.of(TARGET_PIPELINE_INACTIVE, location="pipeline", code=repr(definition.pipeline))]
623
+ if target.definition is not None and target.definition.sources:
624
+ from dirigent_core.sources.rules import triggers_issues
625
+
626
+ return triggers_issues(target.code, definition.triggers)
610
627
  return _trigger_issues(definition, format_checker, against=target.definition, sensors=sensors)
611
628
 
612
629
 
613
630
  def _requirement_issues(
614
631
  definition: PipelineDefinition,
615
632
  catalog: Catalog,
616
- connections: set[str],
617
- pipelines: set[str],
633
+ connections: set[str] | None,
634
+ pipelines: set[str] | None,
618
635
  schemes: set[str] | None,
619
636
  schemas: set[str] | None,
620
637
  ) -> list[ValidationIssue]:
621
- """Check the ``requires`` preflight first, with one complete list of what is missing."""
638
+ """Check the ``requires`` preflight first, with one complete list of what is missing.
639
+
640
+ A family passed as None is one the caller does not know, and is not checked.
641
+ """
622
642
  installed = {entry.id for entry in catalog.blocks}
623
643
  issues = [
624
644
  ValidationIssue.of(REQUIRED_BLOCK_MISSING, location=f"requires.blocks.{index}", block=repr(block))
@@ -628,12 +648,12 @@ def _requirement_issues(
628
648
  issues.extend(
629
649
  ValidationIssue.of(REQUIRED_CONNECTION_MISSING, location=f"requires.connections.{index}", code=repr(name))
630
650
  for index, name in enumerate(definition.requires.connections)
631
- if name not in connections
651
+ if connections is not None and name not in connections
632
652
  )
633
653
  issues.extend(
634
654
  ValidationIssue.of(REQUIRED_PIPELINE_MISSING, location=f"requires.pipelines.{index}", code=repr(name))
635
655
  for index, name in enumerate(definition.requires.pipelines)
636
- if name not in pipelines and name != definition.code
656
+ if pipelines is not None and name not in pipelines and name != definition.code
637
657
  )
638
658
  if schemes is not None:
639
659
  registered = ", ".join(sorted(schemes)) or "none are registered"
@@ -662,7 +682,7 @@ def _requirement_issues(
662
682
  def _step_block_issues(
663
683
  definition: PipelineDefinition,
664
684
  catalog: Catalog,
665
- connections: set[str],
685
+ connections: set[str] | None,
666
686
  allowed: set[str] | None = None,
667
687
  schemes: set[str] | None = None,
668
688
  blocks: Mapping[str, Any] | None = None,
@@ -672,6 +692,8 @@ def _step_block_issues(
672
692
  issues: list[ValidationIssue] = []
673
693
  for name in sorted(definition.steps):
674
694
  step = definition.steps[name]
695
+ if step.block is None:
696
+ continue
675
697
  entry = catalog.block(step.block)
676
698
  if entry is None:
677
699
  issues.append(
@@ -794,10 +816,10 @@ def _config_issues(step: str, config: JsonMap, schema: JsonMap) -> list[Validati
794
816
  return issues
795
817
 
796
818
 
797
- def _connection_issues(step: str, config: JsonMap, connections: set[str]) -> list[ValidationIssue]:
798
- """Refuse a step naming a connection this instance does not hold."""
819
+ def _connection_issues(step: str, config: JsonMap, connections: set[str] | None) -> list[ValidationIssue]:
820
+ """Refuse a step naming a connection this instance does not hold, when the caller knows what it holds."""
799
821
  named = config.get("connection")
800
- if not isinstance(named, str) or has_reference(named) or named in connections:
822
+ if connections is None or not isinstance(named, str) or has_reference(named) or named in connections:
801
823
  return []
802
824
  available = ", ".join(sorted(connections)) or "this instance has no connections"
803
825
  return [
@@ -851,6 +873,12 @@ def _reference_issues(definition: PipelineDefinition) -> list[ValidationIssue]:
851
873
  problem = _reference_problem(reference.strip(), definition, name, upstream, declared, family)
852
874
  if problem is not None:
853
875
  issues.append(_at(f"steps.{name}.config", problem))
876
+ if step.reply is not None:
877
+ written = cast("JsonValue", step.reply.model_dump(mode="json", exclude={"timeout"}))
878
+ for reference in sorted(set(references_in(written))):
879
+ problem = _reference_problem(reference.strip(), definition, name, upstream, declared, family)
880
+ if problem is not None:
881
+ issues.append(_at(f"steps.{name}.reply", problem))
854
882
  issues.extend(_fan_out_literal_issues(name, step.for_each))
855
883
  issues.extend(_adoption_issues(definition, name))
856
884
  for reference in sorted(set(references_in(cast("JsonValue", step.for_each)))):
@@ -952,6 +980,14 @@ def _reference_problem(
952
980
  if target not in upstream:
953
981
  return Issue.of(REFERENCE_NOT_UPSTREAM, reference=reference, target=repr(target), step=repr(step))
954
982
  return None
983
+ case ["steps", target, "error", *path]:
984
+ if target not in definition.steps:
985
+ return Issue.of(REFERENCE_UNKNOWN_STEP, reference=reference, step=repr(target))
986
+ if target not in upstream:
987
+ return Issue.of(REFERENCE_ERROR_NOT_UPSTREAM, reference=reference, target=repr(target), step=repr(step))
988
+ if path[:1] not in (["code"], ["message"], ["params"]) or (path[:1] != ["params"] and len(path) > 1):
989
+ return Issue.of(REFERENCE_MALFORMED_ERROR, reference=reference)
990
+ return None
955
991
  case ["steps", target, "items"]:
956
992
  return Issue.of(REFERENCE_GRID_IN_CONFIG, reference=reference, target=repr(target), bare_target=target)
957
993
  case ["steps", target, "item", "output", *_]:
@@ -1032,7 +1068,9 @@ def summarize(definition: Document) -> DocumentSummary:
1032
1068
  pipeline = definition.pipeline if isinstance(definition, TriggersDefinition) else None
1033
1069
  steps = sorted(definition.steps) if isinstance(definition, PipelineDefinition) else []
1034
1070
  blocks = (
1035
- sorted({step.block for step in definition.steps.values()}) if isinstance(definition, PipelineDefinition) else []
1071
+ sorted({step.block for step in definition.steps.values() if step.block is not None})
1072
+ if isinstance(definition, PipelineDefinition)
1073
+ else []
1036
1074
  )
1037
1075
  return DocumentSummary(
1038
1076
  kind=definition.kind,
@@ -36,6 +36,7 @@ from dirigent_core.engine.references import ReferenceScope, UnknownReference, re
36
36
  from dirigent_core.engine.runs import (
37
37
  Attribution,
38
38
  EngineRuns,
39
+ Entry,
39
40
  FanOutError,
40
41
  Provenance,
41
42
  RunCreationError,
@@ -62,6 +63,7 @@ __all__ = [
62
63
  "KIND_PIPELINE",
63
64
  "KIND_TRIGGERS",
64
65
  "Attribution",
66
+ "Entry",
65
67
  "BufferedLogger",
66
68
  "ClaimedUnit",
67
69
  "ConcurrencyPolicy",