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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -2144,6 +2144,9 @@
// organisation-scoped like `active`. Same ordering rule: it MUST stay above
// the `{uuid}` route or `show('completed')` answers it with a 404.
['name' => 'flowRun#completedForSubject', 'url' => '/api/flow-runs/completed', 'verb' => 'GET'],
// Per flow: newest run and runs since a moment (flow-run-summary). Same
// ordering rule: above `{uuid}`, or `show('summary')` answers with a 404.
['name' => 'flowRun#summary', 'url' => '/api/flow-runs/summary', 'verb' => 'GET'],
['name' => 'flowRun#show', 'url' => '/api/flow-runs/{uuid}', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']],
['name' => 'flowRun#objects', 'url' => '/api/flow-runs/{uuid}/objects', 'verb' => 'GET', 'requirements' => ['uuid' => '[^/]+']],
['name' => 'flowRun#retry', 'url' => '/api/flow-runs/{uuid}/retry', 'verb' => 'POST', 'requirements' => ['uuid' => '[^/]+']],
Expand Down
136 changes: 135 additions & 1 deletion lib/Controller/FlowRunController.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

namespace OCA\OpenRegister\Controller;

use DateTime;
use OCA\OpenRegister\Db\AuditFlowAttribution;
use OCA\OpenRegister\Db\FlowRun;
use OCA\OpenRegister\Db\FlowRunMapper;
Expand Down Expand Up @@ -142,12 +143,17 @@ public function __construct(
/**
* List runs, newest first, optionally filtered by flow and status.
*
* `flowIds` (comma separated) narrows to any of several flows and `since`
* (ISO 8601) to runs created at or after that moment; an unreadable
* `since` is a 400 rather than an unfiltered list.
*
* @return JSONResponse The runs plus the paging window.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @spec openspec/changes/or-flow-tooling/specs/flow-tooling/spec.md
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
#[NoAdminRequired]
#[NoCSRFRequired]
Expand Down Expand Up @@ -196,13 +202,20 @@ public function index(): JSONResponse {
}
}

$since = $this->sinceParam();
if ($since === false) {
return $this->badSince();
}

$runs = $this->mapper->findAllRuns(
flowId: $flowFilter,
status: $statusFilter,
limit: $limit,
offset: $offset,
requesterUid: $requesterUid,
ownedFlowIds: $ownedFlowIds
ownedFlowIds: $ownedFlowIds,
since: $since,
flowIds: $this->flowIdsParam()
);

return new JSONResponse(
Expand All @@ -215,6 +228,127 @@ public function index(): JSONResponse {

}//end index()

/**
* Per flow: the newest run and the number of runs since a moment.
*
* The read behind a flow list's "last run" and "runs this week" columns, so
* a list of flows does not page through every run to draw two cells.
* `flowIds` (comma separated, at most 200) names the flows; `since`
* (ISO 8601) defaults to seven days ago. Scoped exactly like `index()`: a
* non-admin counts the runs they triggered plus the runs of flows they own,
* so a summary never counts a run the history would hide.
*
* @return JSONResponse `{results: {flowId: {lastRun, runsSince}}, since}`.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function summary(): JSONResponse {
$since = $this->sinceParam();
if ($since === false) {
return $this->badSince();
}

if ($since === null) {
$since = new DateTime('-7 days');
}

$flowIds = array_slice($this->flowIdsParam(), 0, 200);
$sinceText = $since->format(DateTime::ATOM);

$requesterUid = null;
$ownedFlowIds = [];
if ($this->isAdmin() === false) {
$requesterUid = $this->callerUid();
if ($requesterUid === null || $requesterUid === '') {
return new JSONResponse(['results' => (object) [], 'since' => $sinceText]);
}

$ownedFlowIds = $this->flowIdsOwnedByCaller();
}

$summary = $this->mapper->summarizeByFlow(
flowIds: $flowIds,
since: $since,
requesterUid: $requesterUid,
ownedFlowIds: $ownedFlowIds
);

return new JSONResponse(['results' => (object) $summary, 'since' => $sinceText]);
}//end summary()

/**
* The `flowIds` request parameter as a list of non-empty ids.
*
* Accepts a comma-separated string or an array, so both `?flowIds=a,b` and
* `?flowIds[]=a&flowIds[]=b` work.
*
* @return array<int, string> The flow ids, empty when none were given.
*
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
private function flowIdsParam(): array {
$raw = $this->request->getParam('flowIds');
if (is_string($raw) === true) {
$raw = explode(',', $raw);
}

if (is_array($raw) === false) {
return [];
}

$ids = [];
foreach ($raw as $id) {
if (is_string($id) === true && trim($id) !== '') {
$ids[] = trim($id);
}
}

return array_values(array_unique($ids));
}//end flowIdsParam()

/**
* The `since` request parameter as a moment.
*
* @return DateTime|false|null The moment, null when absent, false when unreadable.
*
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
private function sinceParam(): DateTime|false|null {
$raw = $this->request->getParam('since');
if ($raw === null || (is_string($raw) === true && trim($raw) === '')) {
return null;
}

if (is_string($raw) === false) {
return false;
}

try {
return new DateTime(trim($raw));
} catch (Throwable) {
return false;
}
}//end sinceParam()

/**
* The 400 for a `since` that is not a date.
*
* @return JSONResponse The refusal naming the parameter.
*
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
private function badSince(): JSONResponse {
return new JSONResponse(
['error' => 'The since parameter must be an ISO 8601 date or date-time.'],
Http::STATUS_BAD_REQUEST
);
}//end badSince()

/**
* The runs that are still going, for the caller's organisation.
*
Expand Down
154 changes: 154 additions & 0 deletions lib/Db/FlowRunMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,14 @@ public function lockByUuid(string $uuid): FlowRun {
* (administrators and system reads only).
* @param array<int, string> $ownedFlowIds Flow ids the caller owns; runs of these
* are visible regardless of who triggered them.
* @param DateTime|null $since Only runs created at or after this moment.
* @param array<int, string> $flowIds Restrict to any of these flows; empty means no restriction.
*
* @return array<int, FlowRun> The runs.
*
* @spec openspec/changes/or-flow-runs/specs/flow-runs/spec.md
* @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
public function findAllRuns(
?string $flowId = null,
Expand All @@ -230,6 +233,8 @@ public function findAllRuns(
int $offset = 0,
?string $requesterUid = null,
array $ownedFlowIds = [],
?DateTime $since = null,
array $flowIds = [],
): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
Expand All @@ -242,6 +247,14 @@ public function findAllRuns(
$qb->andWhere($qb->expr()->eq('flow_id', $qb->createNamedParameter($flowId)));
}

if (empty($flowIds) === false) {
$qb->andWhere($qb->expr()->in('flow_id', $qb->createNamedParameter(array_values($flowIds), IQueryBuilder::PARAM_STR_ARRAY)));
}

if ($since !== null) {
$qb->andWhere($qb->expr()->gte('created', $qb->createNamedParameter($since, IQueryBuilder::PARAM_DATETIME_MUTABLE)));
}

if ($status !== null && $status !== '') {
$qb->andWhere($qb->expr()->eq('status', $qb->createNamedParameter($status)));
}
Expand All @@ -251,6 +264,147 @@ public function findAllRuns(
return $this->findEntities(query: $qb);
}//end findAllRuns()

/**
* Per flow: its most recent run and how many runs started since a moment.
*
* The read behind a flow list's "last run" and "runs this week" columns.
* Both halves apply the same visibility rule as {@see findAllRuns}, so the
* numbers a list shows never count a run its history would hide. Every
* requested flow gets an entry, a flow with no visible run included, so a
* caller can tell "never ran" from "not asked".
*
* @param array<int, string> $flowIds The flows to summarise.
* @param DateTime $since Count runs created at or after this.
* @param string|null $requesterUid The caller, or null for no scoping.
* @param array<int, string> $ownedFlowIds Flow ids the caller owns.
*
* @return array<string, array{lastRun: array<string, mixed>|null, runsSince: int}> Keyed by flow id.
*
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
public function summarizeByFlow(
array $flowIds,
DateTime $since,
?string $requesterUid = null,
array $ownedFlowIds = [],
): array {
$summary = [];
foreach ($flowIds as $flowId) {
$flowId = trim((string)$flowId);
if ($flowId !== '') {
$summary[$flowId] = ['lastRun' => null, 'runsSince' => 0];
}
}

if ($summary === []) {
return [];
}

$ids = array_keys($summary);
$ids = array_map(static fn ($id): string => (string)$id, $ids);

foreach ($this->countSinceByFlow(flowIds: $ids, since: $since, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds) as $flowId => $count) {
if (isset($summary[$flowId]) === true) {
$summary[$flowId]['runsSince'] = $count;
}
}

foreach ($this->latestRunsByFlow(flowIds: $ids, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds) as $run) {
$flowId = (string)$run->getFlowId();
if (isset($summary[$flowId]) === false) {
continue;
}

$summary[$flowId]['lastRun'] = [
'uuid' => $run->getUuid(),
'status' => $run->getStatus(),
'error' => $run->getError(),
'created' => $run->getCreated()?->format(DateTime::ATOM),
'updated' => $run->getUpdated()?->format(DateTime::ATOM),
];
}

return $summary;
}//end summarizeByFlow()

/**
* How many visible runs each flow started since a moment.
*
* @param array<int, string> $flowIds The flows to count.
* @param DateTime $since Count runs created at or after this.
* @param string|null $requesterUid The caller, or null for no scoping.
* @param array<int, string> $ownedFlowIds Flow ids the caller owns.
*
* @return array<string, int> Run count keyed by flow id; flows without runs are absent.
*
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
public function countSinceByFlow(array $flowIds, DateTime $since, ?string $requesterUid, array $ownedFlowIds): array {
$qb = $this->db->getQueryBuilder();
$qb->select('flow_id')
->selectAlias($qb->func()->count('id'), 'total')
->from($this->getTableName())
->where($qb->expr()->in('flow_id', $qb->createNamedParameter($flowIds, IQueryBuilder::PARAM_STR_ARRAY)))
->andWhere($qb->expr()->gte('created', $qb->createNamedParameter($since, IQueryBuilder::PARAM_DATETIME_MUTABLE)))
->groupBy('flow_id');

$this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds);

$counts = [];
$result = $qb->executeQuery();
while (($row = $result->fetch()) !== false) {
$counts[(string)$row['flow_id']] = (int)$row['total'];
}

$result->closeCursor();

return $counts;
}//end countSinceByFlow()

/**
* The newest visible run of each flow, without its log or marking.
*
* Two queries rather than one per flow: the newest id per flow, then those
* rows. Only the columns a summary shows are read, so a list of a hundred
* flows never loads a hundred run logs.
*
* @param array<int, string> $flowIds The flows to read.
* @param string|null $requesterUid The caller, or null for no scoping.
* @param array<int, string> $ownedFlowIds Flow ids the caller owns.
*
* @return array<int, FlowRun> At most one run per flow.
*
* @spec openspec/changes/flow-run-summary/specs/flow-run-summary/spec.md
*/
public function latestRunsByFlow(array $flowIds, ?string $requesterUid, array $ownedFlowIds): array {
$qb = $this->db->getQueryBuilder();
$qb->selectAlias($qb->func()->max('id'), 'latest')
->from($this->getTableName())
->where($qb->expr()->in('flow_id', $qb->createNamedParameter($flowIds, IQueryBuilder::PARAM_STR_ARRAY)))
->groupBy('flow_id');

$this->scopeToVisible(qb: $qb, requesterUid: $requesterUid, ownedFlowIds: $ownedFlowIds);

$latest = [];
$result = $qb->executeQuery();
while (($row = $result->fetch()) !== false) {
$latest[] = (int)$row['latest'];
}

$result->closeCursor();

if ($latest === []) {
return [];
}

$rows = $this->db->getQueryBuilder();
$rows->select('id', 'uuid', 'flow_id', 'status', 'error', 'created', 'updated')
->from($this->getTableName())
->where($rows->expr()->in('id', $rows->createNamedParameter($latest, IQueryBuilder::PARAM_INT_ARRAY)));

return $this->findEntities(query: $rows);
}//end latestRunsByFlow()

/**
* Narrow a run query to what one caller may see.
*
Expand Down
Loading
Loading