@honua/react 0.1.7-beta.0 → 0.1.9-beta.0

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 (131) hide show
  1. package/control-plane/admin-client.d.ts +87 -0
  2. package/control-plane/admin-client.d.ts.map +1 -0
  3. package/control-plane/admin-client.js +169 -0
  4. package/control-plane/client.d.ts.map +1 -1
  5. package/control-plane/client.js +21 -3
  6. package/control-plane/commands/catalog.d.ts +133 -0
  7. package/control-plane/commands/catalog.d.ts.map +1 -0
  8. package/control-plane/commands/catalog.js +353 -0
  9. package/control-plane/commands/errors.d.ts +96 -0
  10. package/control-plane/commands/errors.d.ts.map +1 -0
  11. package/control-plane/commands/errors.js +130 -0
  12. package/control-plane/commands/identity.d.ts +71 -0
  13. package/control-plane/commands/identity.d.ts.map +1 -0
  14. package/control-plane/commands/identity.js +0 -0
  15. package/control-plane/commands/index.d.ts +39 -0
  16. package/control-plane/commands/index.d.ts.map +1 -0
  17. package/control-plane/commands/index.js +34 -0
  18. package/control-plane/commands/runtime.d.ts +146 -0
  19. package/control-plane/commands/runtime.d.ts.map +1 -0
  20. package/control-plane/commands/runtime.js +318 -0
  21. package/control-plane/commands/types.d.ts +226 -0
  22. package/control-plane/commands/types.d.ts.map +1 -0
  23. package/control-plane/commands/types.js +30 -0
  24. package/control-plane/commands/validate.d.ts +27 -0
  25. package/control-plane/commands/validate.d.ts.map +1 -0
  26. package/control-plane/commands/validate.js +132 -0
  27. package/control-plane/generated/admin-api.d.ts +30263 -0
  28. package/control-plane/generated/admin-api.d.ts.map +1 -0
  29. package/control-plane/generated/admin-api.js +5 -0
  30. package/control-plane/generated/admin-operations.d.ts +4053 -0
  31. package/control-plane/generated/admin-operations.d.ts.map +1 -0
  32. package/control-plane/generated/admin-operations.js +820 -0
  33. package/control-plane/index.d.ts +11 -0
  34. package/control-plane/index.d.ts.map +1 -1
  35. package/control-plane/index.js +8 -0
  36. package/control-plane/types.d.ts +14 -0
  37. package/control-plane/types.d.ts.map +1 -1
  38. package/core/client.js +1 -1
  39. package/core/credential-redaction.d.ts +163 -0
  40. package/core/credential-redaction.d.ts.map +1 -0
  41. package/core/credential-redaction.js +389 -0
  42. package/core/error-classifications.d.ts +15 -0
  43. package/core/error-classifications.d.ts.map +1 -1
  44. package/core/error-classifications.js +15 -0
  45. package/core/error-code-registry.d.ts +15 -0
  46. package/core/error-code-registry.d.ts.map +1 -1
  47. package/core/error-code-registry.js +15 -0
  48. package/core/grpc-adapter.d.ts +2 -2
  49. package/core/grpc-adapter.d.ts.map +1 -1
  50. package/core/grpc-adapter.js +30 -6
  51. package/core/job-run-errors.d.ts +10 -0
  52. package/core/job-run-errors.d.ts.map +1 -0
  53. package/core/job-run-errors.js +14 -0
  54. package/core/job-run-lifecycle.d.ts +34 -0
  55. package/core/job-run-lifecycle.d.ts.map +1 -0
  56. package/core/job-run-lifecycle.js +193 -0
  57. package/core/ogc-processes.d.ts +38 -31
  58. package/core/ogc-processes.d.ts.map +1 -1
  59. package/core/ogc-processes.js +123 -173
  60. package/core/process-runner.js +15 -100
  61. package/core/surfaces.d.ts +2 -8
  62. package/core/surfaces.d.ts.map +1 -1
  63. package/core/surfaces.js +21 -112
  64. package/core/types.d.ts +7 -6
  65. package/core/types.d.ts.map +1 -1
  66. package/gen/geospatial/v1/common_pb.d.ts +514 -0
  67. package/gen/geospatial/v1/common_pb.d.ts.map +1 -0
  68. package/gen/geospatial/v1/common_pb.js +335 -0
  69. package/gen/geospatial/v1/execution_types_pb.d.ts +1186 -0
  70. package/gen/geospatial/v1/execution_types_pb.d.ts.map +1 -0
  71. package/gen/geospatial/v1/execution_types_pb.js +396 -0
  72. package/gen/geospatial/v1/feature_service_pb.d.ts +403 -0
  73. package/gen/geospatial/v1/feature_service_pb.d.ts.map +1 -0
  74. package/gen/geospatial/v1/feature_service_pb.js +47 -0
  75. package/gen/geospatial/v1/index.d.ts +5 -0
  76. package/gen/geospatial/v1/index.d.ts.map +1 -0
  77. package/gen/geospatial/v1/index.js +9 -0
  78. package/gen/geospatial/v1/spatial_types_pb.d.ts +285 -0
  79. package/gen/geospatial/v1/spatial_types_pb.d.ts.map +1 -0
  80. package/gen/geospatial/v1/spatial_types_pb.js +58 -0
  81. package/gen/geospatial/v1/workspace_artifact_types_pb.d.ts +379 -0
  82. package/gen/geospatial/v1/workspace_artifact_types_pb.d.ts.map +1 -0
  83. package/gen/geospatial/v1/workspace_artifact_types_pb.js +151 -0
  84. package/generated-app/projection.d.ts.map +1 -1
  85. package/generated-app/projection.js +10 -1
  86. package/package.json +2 -2
  87. package/query-planner/grpc.d.ts +4 -4
  88. package/query-planner/grpc.d.ts.map +1 -1
  89. package/query-planner/grpc.js +11 -11
  90. package/query-planner/types.d.ts +4 -4
  91. package/query-planner/types.d.ts.map +1 -1
  92. package/runtime/errors.d.ts +1 -1
  93. package/runtime/errors.d.ts.map +1 -1
  94. package/runtime/errors.js +2 -0
  95. package/runtime/generated/map-package-schema-meta.d.ts +11 -0
  96. package/runtime/generated/map-package-schema-meta.d.ts.map +1 -0
  97. package/runtime/generated/map-package-schema-meta.js +12 -0
  98. package/runtime/generated/map-package-schema-validator.d.ts +16 -0
  99. package/runtime/generated/map-package-schema-validator.d.ts.map +1 -0
  100. package/runtime/generated/map-package-schema-validator.js +1422 -0
  101. package/runtime/index.d.ts +5 -1
  102. package/runtime/index.d.ts.map +1 -1
  103. package/runtime/index.js +6 -0
  104. package/runtime/map-package-export.d.ts +151 -0
  105. package/runtime/map-package-export.d.ts.map +1 -0
  106. package/runtime/map-package-export.js +636 -0
  107. package/runtime/map-package-validation.d.ts +21 -1
  108. package/runtime/map-package-validation.d.ts.map +1 -1
  109. package/runtime/map-package-validation.js +132 -95
  110. package/runtime/map-package.d.ts +80 -0
  111. package/runtime/map-package.d.ts.map +1 -1
  112. package/studio/chart-spec.d.ts +6 -6
  113. package/studio/chart-spec.d.ts.map +1 -1
  114. package/studio/chart-spec.js +6 -6
  115. package/studio/index.d.ts +17 -7
  116. package/studio/index.d.ts.map +1 -1
  117. package/studio/index.js +15 -5
  118. package/studio/lifecycle-client.d.ts +262 -22
  119. package/studio/lifecycle-client.d.ts.map +1 -1
  120. package/studio/lifecycle-client.js +524 -24
  121. package/studio/lifecycle-errors.d.ts +8 -2
  122. package/studio/lifecycle-errors.d.ts.map +1 -1
  123. package/studio/lifecycle-errors.js +6 -1
  124. package/studio/lifecycle-types.d.ts +228 -8
  125. package/studio/lifecycle-types.d.ts.map +1 -1
  126. package/studio/publication-status.d.ts +55 -0
  127. package/studio/publication-status.d.ts.map +1 -0
  128. package/studio/publication-status.js +103 -0
  129. package/gen/honua/v1/feature_service_pb.d.ts +0 -918
  130. package/gen/honua/v1/feature_service_pb.d.ts.map +0 -1
  131. package/gen/honua/v1/feature_service_pb.js +0 -375
@@ -23,10 +23,9 @@
23
23
  *
24
24
  * @module
25
25
  */
26
- import { HonuaJobPollTimeoutError, isJobTerminal } from "../contract/jobs.js";
27
- import { HonuaSdkError } from "./error-envelope.js";
26
+ import { isJobTerminal } from "../contract/jobs.js";
28
27
  import { HonuaCapabilityNotSupportedError } from "./errors.js";
29
- import { hasOgcConformanceClass } from "./ogc-conformance.js";
28
+ import { JobRunLifecycle } from "./job-run-lifecycle.js";
30
29
  import { createOgcMetadataParams, mergeHeaders } from "./wire-shared.js";
31
30
  const DEFAULT_POLL_INTERVAL_MS = 1_000;
32
31
  /**
@@ -35,7 +34,6 @@ const DEFAULT_POLL_INTERVAL_MS = 1_000;
35
34
  * never reaches a terminal state must not poll forever. Callers who genuinely
36
35
  * want a longer wait pass their own `deadlineMs`.
37
36
  */
38
- const DEFAULT_POLL_DEADLINE_MS = 600_000;
39
37
  /** Honua facade path prefix for OGC API Processes. */
40
38
  const PROCESSES_FACADE_BASE = "/ogc/processes";
41
39
  /**
@@ -49,7 +47,11 @@ const PROCESSES_CONFORMANCE = {
49
47
  core: "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core",
50
48
  dismiss: "http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/dismiss",
51
49
  };
52
- /** Substring matchers tolerant of OGC re-issuing the class URIs under a new part number. */
50
+ /**
51
+ * Suffix matchers tolerant of a server publishing the class under a different
52
+ * host or path prefix, but *not* of a different class that merely starts with
53
+ * one of these. See {@link declaresConformanceClass}.
54
+ */
53
55
  const PROCESSES_CONFORMANCE_MATCH = {
54
56
  core: "processes-1/1.0/conf/core",
55
57
  dismiss: "processes-1/1.0/conf/dismiss",
@@ -77,6 +79,36 @@ const JOB_CONTROL = {
77
79
  function capabilityRefusal(capability, sourceId, context) {
78
80
  return new HonuaCapabilityNotSupportedError(capability, "ogc-processes", sourceId, { context });
79
81
  }
82
+ /**
83
+ * `true` when the server declares exactly the named conformance class.
84
+ *
85
+ * The public `hasOgcConformanceClass()` is a documented *substring* test, which
86
+ * is the right tool for a caller probing an extension they already recognise.
87
+ * It is the wrong tool for a fail-closed gate: an unrecognised class that
88
+ * happens to extend one of ours — `…/conf/dismiss-disabled`, `…/conf/core-lite`,
89
+ * or a vendor class carrying the URI in a query string — would widen what the
90
+ * client is willing to send. So the gate anchors the match at the end of the
91
+ * class URI (ignoring a trailing slash, query, or fragment): a prefix the
92
+ * server chose is still tolerated, an unknown longer class is not.
93
+ */
94
+ function declaresConformanceClass(conformance, classSuffix) {
95
+ for (const uri of conformance?.conformsTo ?? []) {
96
+ if (typeof uri !== "string")
97
+ continue;
98
+ const withoutFragment = uri.split("#")[0] ?? "";
99
+ let path = withoutFragment.split("?")[0] ?? "";
100
+ // Trailing slashes are trimmed by scanning, not by a `/+$/` regex: this
101
+ // string is server input, and that pattern backtracks on a URI made of
102
+ // many slashes.
103
+ let end = path.length;
104
+ while (end > 0 && path.charCodeAt(end - 1) === 47)
105
+ end -= 1;
106
+ path = path.slice(0, end);
107
+ if (path.endsWith(classSuffix))
108
+ return true;
109
+ }
110
+ return false;
111
+ }
80
112
  /** `true` when the declaration list contains `option` (case-insensitive). */
81
113
  function declares(options, option) {
82
114
  if (!options)
@@ -226,9 +258,12 @@ export class HonuaOgcProcesses {
226
258
  declaredConformance;
227
259
  conformancePromise;
228
260
  /**
229
- * `jobControlOptions` learned from `describe()` calls made through this
230
- * handle. Callers that follow the natural discover → describe → execute flow
231
- * therefore get process-level gating for free, with no extra round trip.
261
+ * `jobControlOptions` learned from `describe()` / `list()` calls made through
262
+ * this handle. Callers that follow the natural discover → describe → execute
263
+ * flow therefore get process-level gating for free, with no extra round trip.
264
+ *
265
+ * Each entry remembers which source taught it, because a later listing must
266
+ * not be able to downgrade what a description already established.
232
267
  */
233
268
  jobControlByProcess = new Map();
234
269
  constructor(options) {
@@ -253,19 +288,36 @@ export class HonuaOgcProcesses {
253
288
  async list(request = {}) {
254
289
  const response = await this.client.listOgcProcesses(this.withBase(request));
255
290
  for (const process of response?.processes ?? []) {
256
- if (process?.id && process.jobControlOptions) {
257
- this.jobControlByProcess.set(process.id, [...process.jobControlOptions]);
258
- }
291
+ // A summary that omits `jobControlOptions` advertises no execution mode;
292
+ // recorded as an empty declaration so the mode gate refuses rather than
293
+ // reading silence as consent — but only where nothing better is known.
294
+ if (process?.id)
295
+ this.rememberJobControl(process.id, process.jobControlOptions, "summary");
259
296
  }
260
297
  return response;
261
298
  }
262
299
  async describe(processId, request = {}) {
263
300
  const description = await this.client.getOgcProcess(this.withBase({ ...request, processId }));
264
- if (description?.jobControlOptions) {
265
- this.jobControlByProcess.set(processId, [...description.jobControlOptions]);
266
- }
301
+ // As in `list()`: an omitted `jobControlOptions` is an empty declaration.
302
+ // A description is the authoritative source, so this always wins.
303
+ this.rememberJobControl(processId, description?.jobControlOptions, "description");
267
304
  return description;
268
305
  }
306
+ /**
307
+ * Record what a process advertises, keeping the more authoritative source.
308
+ *
309
+ * Silence is still not consent — an omitted `jobControlOptions` is stored as
310
+ * an empty declaration, and an explicit mode against it is refused. But a
311
+ * process summary may legally omit what the full description declares, so a
312
+ * `list()` refresh after a `describe()` must not overwrite the description's
313
+ * answer with the listing's silence. Refusing an execution the server did
314
+ * declare is as wrong as permitting one it did not.
315
+ */
316
+ rememberJobControl(processId, options, source) {
317
+ if (source === "summary" && this.jobControlByProcess.get(processId)?.source === "description")
318
+ return;
319
+ this.jobControlByProcess.set(processId, { options: [...(options ?? [])], source });
320
+ }
269
321
  /**
270
322
  * Submit a process for execution and return an `IJobRun` — always, so the
271
323
  * caller reads results through one surface whichever Core response shape the
@@ -289,7 +341,7 @@ export class HonuaOgcProcesses {
289
341
  const conformance = await this.assertExecutionDeclared(request);
290
342
  const accepted = await this.client.executeOgcProcess(executeRequest);
291
343
  const processId = accepted.processID ?? request.processId;
292
- const jobControlOptions = request.jobControlOptions ?? this.jobControlByProcess.get(request.processId);
344
+ const jobControlOptions = request.jobControlOptions ?? this.jobControlByProcess.get(request.processId)?.options;
293
345
  if (accepted.synchronous === true) {
294
346
  // Core assigns no job identifier to a synchronous execution; the results
295
347
  // document is already in hand, so the run starts terminal.
@@ -317,7 +369,7 @@ export class HonuaOgcProcesses {
317
369
  /** Adopt an existing job by id (useful when reconnecting after navigation). */
318
370
  job(jobId, options = {}) {
319
371
  const { basePath } = this.withBase(options);
320
- const jobControlOptions = options.processId ? this.jobControlByProcess.get(options.processId) : undefined;
372
+ const jobControlOptions = options.processId ? this.jobControlByProcess.get(options.processId)?.options : undefined;
321
373
  return new HonuaOgcProcessJobRun({
322
374
  client: this.client,
323
375
  jobId,
@@ -364,7 +416,11 @@ export class HonuaOgcProcesses {
364
416
  */
365
417
  async assertExecutionDeclared(request) {
366
418
  const conformance = await this.resolveConformance(request.signal);
367
- if (conformance?.conformsTo && !hasOgcConformanceClass(conformance, PROCESSES_CONFORMANCE_MATCH.core)) {
419
+ // Under `"strict"` the declaration was fetched, so silence is an answer:
420
+ // a document that declares no Core class — or none at all — refuses. Under
421
+ // `"advertised"` only a declaration actually in hand can refuse.
422
+ const declarationInHand = conformance?.conformsTo !== undefined || this.capabilityPolicy === "strict";
423
+ if (declarationInHand && !declaresConformanceClass(conformance, PROCESSES_CONFORMANCE_MATCH.core)) {
368
424
  throw capabilityRefusal("processes.execute", request.processId, {
369
425
  missingClass: PROCESSES_CONFORMANCE.core,
370
426
  construct: "execute",
@@ -375,11 +431,15 @@ export class HonuaOgcProcesses {
375
431
  // shapes land on the same `IJobRun`. Only an explicit preference is gated.
376
432
  if (mode === "auto")
377
433
  return conformance;
378
- let jobControlOptions = request.jobControlOptions ?? this.jobControlByProcess.get(request.processId);
434
+ let jobControlOptions = request.jobControlOptions ?? this.jobControlByProcess.get(request.processId)?.options;
379
435
  if (!jobControlOptions && this.capabilityPolicy === "strict") {
380
436
  const description = await this.describe(request.processId, request.signal ? { signal: request.signal } : {});
381
- jobControlOptions = description.jobControlOptions;
437
+ // An omitted `jobControlOptions` is an empty declaration, never a wildcard:
438
+ // the server was asked and advertised no execution mode at all.
439
+ jobControlOptions = description.jobControlOptions ?? [];
382
440
  }
441
+ // Only a process this handle has never seen described is unknown; every
442
+ // observed description contributes a declaration, empty or otherwise.
383
443
  if (!jobControlOptions)
384
444
  return conformance;
385
445
  const required = mode === "sync" ? JOB_CONTROL.sync : JOB_CONTROL.async;
@@ -413,11 +473,7 @@ export class HonuaOgcProcessJobRun {
413
473
  /** Server-advertised routes, refreshed from every `links[]` the job reports. */
414
474
  statusPath;
415
475
  resultsPath;
416
- currentStatus;
417
- currentProgress;
418
- terminalSnapshot;
419
- terminalPromise;
420
- listeners = new Set();
476
+ lifecycle;
421
477
  constructor(options) {
422
478
  this.client = options.client;
423
479
  this.id = options.jobId;
@@ -433,8 +489,14 @@ export class HonuaOgcProcessJobRun {
433
489
  this.pollFn = options.pollFn ?? ((jobId, signal) => this.fetchStatus(jobId, signal));
434
490
  const initial = options.initialStatus;
435
491
  this.absorbLinks(initial?.links);
436
- this.currentStatus = initial?.status ?? "accepted";
437
- this.currentProgress = progressFromOgcStatus(initial);
492
+ this.lifecycle = new JobRunLifecycle({
493
+ id: this.id,
494
+ initialStatus: initial?.status ?? "accepted",
495
+ initialProgress: progressFromOgcStatus(initial),
496
+ pollIntervalMs: () => this.pollIntervalMs,
497
+ pollBudget: this.pollBudget,
498
+ poll: async (signal) => this.translateOgcStatus(await this.pollFn(this.id, signal)),
499
+ });
438
500
  }
439
501
  async fetchStatus(jobId, signal) {
440
502
  if (this.capabilityPolicy === "strict" && this.statusPath === undefined) {
@@ -470,57 +532,32 @@ export class HonuaOgcProcessJobRun {
470
532
  }
471
533
  }
472
534
  get status() {
473
- return this.currentStatus;
535
+ return this.lifecycle.status;
474
536
  }
475
537
  get progress() {
476
- return this.currentProgress;
538
+ return this.lifecycle.progress;
477
539
  }
478
540
  async poll() {
479
- if (this.terminalSnapshot) {
480
- return this.terminalSnapshot;
481
- }
482
- const ogcStatus = await this.pollFn(this.id);
483
- return this.handleOgcStatus(ogcStatus);
541
+ return this.lifecycle.poll();
484
542
  }
485
543
  watch(listener) {
486
- this.listeners.add(listener);
487
- return () => this.listeners.delete(listener);
544
+ return this.lifecycle.watch(listener);
488
545
  }
489
546
  async results(options = {}) {
490
- if (!this.terminalPromise) {
491
- // Reset the cached promise if the poll loop rejects (abort / deadline /
492
- // attempt cap) so a later results() call can retry rather than being
493
- // permanently poisoned by a transient cancellation.
494
- this.terminalPromise = this.runUntilTerminal(this.resolveBudget(options)).catch((error) => {
495
- this.terminalPromise = undefined;
496
- throw error;
497
- });
498
- }
499
- return this.terminalPromise;
500
- }
501
- /**
502
- * Layer the caller's bounds over the handle's default budget, then guarantee
503
- * a bound exists. NFR-001: `results()` is never an unbounded retry loop, so a
504
- * caller that names neither a deadline nor an attempt cap still inherits
505
- * {@link DEFAULT_POLL_DEADLINE_MS}.
506
- */
507
- resolveBudget(options) {
508
- const merged = { ...this.pollBudget, ...options };
509
- if (merged.deadlineMs !== undefined || merged.maxAttempts !== undefined)
510
- return merged;
511
- return { ...merged, deadlineMs: DEFAULT_POLL_DEADLINE_MS };
547
+ return this.lifecycle.results(options);
512
548
  }
513
549
  async cancel() {
514
- if (this.terminalSnapshot) {
515
- return this.currentStatus;
516
- }
550
+ if (this.lifecycle.terminal)
551
+ return this.lifecycle.status;
517
552
  this.assertDismissDeclared();
553
+ return this.lifecycle.cancel(() => this.cancelSnapshot());
554
+ }
555
+ async cancelSnapshot() {
518
556
  try {
519
557
  // DELETE targets the job resource itself, so it follows the same
520
558
  // advertised route the status polls use rather than re-templating it.
521
559
  const cancelled = await this.client.cancelOgcProcessJob(this.jobRequest(this.statusPath));
522
- const snapshot = await this.handleOgcStatus(cancelled);
523
- return snapshot.status;
560
+ return this.translateOgcStatus(cancelled);
524
561
  }
525
562
  catch (error) {
526
563
  const statusCode = error?.statusCode;
@@ -540,7 +577,7 @@ export class HonuaOgcProcessJobRun {
540
577
  // "completed job" claim cannot be confirmed and the original 409
541
578
  // is the most honest signal.
542
579
  if (statusCode === 404) {
543
- return this.currentStatus;
580
+ return { status: this.lifecycle.status, progress: this.lifecycle.progress };
544
581
  }
545
582
  if (statusCode === 409 && isCompletedJobConflict(error)) {
546
583
  let fresh;
@@ -553,67 +590,24 @@ export class HonuaOgcProcessJobRun {
553
590
  // 409 instead of letting the poll-side error swallow it.
554
591
  throw error;
555
592
  }
556
- const snapshot = await this.handleOgcStatus(fresh);
593
+ const snapshot = await this.translateOgcStatus(fresh);
557
594
  if (!isJobTerminal(snapshot.status)) {
558
595
  throw error;
559
596
  }
560
- return snapshot.status;
597
+ return snapshot;
561
598
  }
562
599
  throw error;
563
600
  }
564
601
  }
565
- async runUntilTerminal(options = {}) {
566
- const { signal } = options;
567
- const baseIntervalMs = options.pollIntervalMs ?? this.pollIntervalMs;
568
- const maxIntervalMs = options.maxPollIntervalMs ?? Math.max(baseIntervalMs, 30_000);
569
- const startedAt = Date.now();
570
- let attempts = 0;
571
- while (!this.terminalSnapshot) {
572
- if (signal?.aborted) {
573
- throw new HonuaJobPollTimeoutError(`Job ${this.id} poll aborted`, "aborted", this.id, this.currentStatus);
574
- }
575
- if (options.maxAttempts !== undefined && attempts >= options.maxAttempts) {
576
- throw new HonuaJobPollTimeoutError(`Job ${this.id} did not reach a terminal state within ${options.maxAttempts} poll attempt(s)`, "max-attempts", this.id, this.currentStatus);
577
- }
578
- let ogcStatus;
579
- try {
580
- ogcStatus = await this.pollFn(this.id, signal);
581
- }
582
- catch (error) {
583
- if (signal?.aborted) {
584
- throw new HonuaJobPollTimeoutError(`Job ${this.id} poll aborted`, "aborted", this.id, this.currentStatus);
585
- }
586
- throw error;
587
- }
588
- attempts += 1;
589
- await this.handleOgcStatus(ogcStatus);
590
- if (this.terminalSnapshot)
591
- break;
592
- if (options.deadlineMs !== undefined && Date.now() - startedAt >= options.deadlineMs) {
593
- throw new HonuaJobPollTimeoutError(`Job ${this.id} did not reach a terminal state within ${options.deadlineMs}ms`, "deadline", this.id, this.currentStatus);
594
- }
595
- // Capped exponential backoff instead of a fixed interval.
596
- const intervalMs = Math.min(maxIntervalMs, baseIntervalMs * 2 ** (attempts - 1));
597
- if (intervalMs > 0) {
598
- await delay(intervalMs, signal);
599
- }
600
- }
601
- if (this.terminalSnapshot.status === "successful" && this.terminalSnapshot.result) {
602
- return this.terminalSnapshot.result;
603
- }
604
- throw makeJobFailedError(this.terminalSnapshot);
605
- }
606
602
  /**
607
603
  * Translate an OGC `statusInfo` payload onto the canonical snapshot
608
604
  * surface, fire watchers, and (for `successful` terminals) fetch the
609
605
  * result document inline so the snapshot's `result.outputs` is
610
606
  * populated by the time `runUntilTerminal` / `poll` resolves.
611
607
  */
612
- async handleOgcStatus(ogcStatus) {
608
+ async translateOgcStatus(ogcStatus) {
613
609
  const status = ogcStatus.status ?? "accepted";
614
610
  const progress = progressFromOgcStatus(ogcStatus);
615
- this.currentStatus = status;
616
- this.currentProgress = progress;
617
611
  // Routes the server publishes on this document win over the Core template.
618
612
  this.absorbLinks(ogcStatus.links);
619
613
  if (status === "successful") {
@@ -634,8 +628,6 @@ export class HonuaOgcProcessJobRun {
634
628
  progress,
635
629
  result: { outputs: results },
636
630
  };
637
- this.terminalSnapshot = snapshot;
638
- this.notify(snapshot);
639
631
  return snapshot;
640
632
  }
641
633
  catch (error) {
@@ -653,9 +645,6 @@ export class HonuaOgcProcessJobRun {
653
645
  message: error instanceof Error ? error.message : String(error),
654
646
  },
655
647
  };
656
- this.currentStatus = "failed";
657
- this.terminalSnapshot = failure;
658
- this.notify(failure);
659
648
  return failure;
660
649
  }
661
650
  }
@@ -666,12 +655,9 @@ export class HonuaOgcProcessJobRun {
666
655
  progress,
667
656
  ...(error ? { error } : {}),
668
657
  };
669
- this.terminalSnapshot = snapshot;
670
- this.notify(snapshot);
671
658
  return snapshot;
672
659
  }
673
660
  const snapshot = { status, progress };
674
- this.notify(snapshot);
675
661
  return snapshot;
676
662
  }
677
663
  /**
@@ -690,6 +676,10 @@ export class HonuaOgcProcessJobRun {
690
676
  * in hand and neither the Dismiss conformance class nor the process's
691
677
  * `jobControlOptions` includes `dismiss`, refuse before issuing a DELETE the
692
678
  * server never advertised.
679
+ *
680
+ * Under `"strict"` the absence of any declaration refuses too: a handle that
681
+ * resolves declarations for itself has no honest reason to DELETE a job on a
682
+ * server that never said it could be dismissed.
693
683
  */
694
684
  assertDismissDeclared() {
695
685
  // Under `"strict"` the DELETE route must have been advertised, exactly as
@@ -703,25 +693,15 @@ export class HonuaOgcProcessJobRun {
703
693
  if (declares(this.jobControlOptions, JOB_CONTROL.dismiss))
704
694
  return;
705
695
  const conformsTo = this.conformance?.conformsTo;
706
- if (!conformsTo)
696
+ if (!conformsTo && this.capabilityPolicy !== "strict")
707
697
  return;
708
- if (hasOgcConformanceClass(this.conformance, PROCESSES_CONFORMANCE_MATCH.dismiss))
698
+ if (declaresConformanceClass(this.conformance, PROCESSES_CONFORMANCE_MATCH.dismiss))
709
699
  return;
710
700
  throw capabilityRefusal("processes.dismiss", this.type, {
711
701
  missingClass: PROCESSES_CONFORMANCE.dismiss,
712
702
  construct: JOB_CONTROL.dismiss,
713
703
  });
714
704
  }
715
- notify(snapshot) {
716
- for (const listener of this.listeners) {
717
- try {
718
- listener(snapshot);
719
- }
720
- catch {
721
- // Listener exceptions must not break the runner; log silently.
722
- }
723
- }
724
- }
725
705
  }
726
706
  /**
727
707
  * `IJobRun` for a synchronous execution (Core §7.11: `200` with the results
@@ -760,18 +740,7 @@ export class HonuaOgcProcessSyncRun {
760
740
  }
761
741
  }
762
742
  /** Error thrown when `IJobRun.results()` resolves a non-success terminal. */
763
- export class HonuaJobFailedError extends HonuaSdkError {
764
- status;
765
- errorCode;
766
- details;
767
- constructor(message, status, errorCode, details) {
768
- super("core.job-failed", message, { context: { status, errorCode } });
769
- this.name = "HonuaJobFailedError";
770
- this.status = status;
771
- this.errorCode = errorCode;
772
- this.details = details;
773
- }
774
- }
743
+ export { HonuaJobFailedError } from "./job-run-errors.js";
775
744
  /**
776
745
  * Honua-server emits problem-details JSON for DELETE /jobs/{id} 409s. The
777
746
  * `title` distinguishes the benign terminal race ("Cannot dismiss
@@ -832,28 +801,6 @@ function clampPercent(value) {
832
801
  return 100;
833
802
  return value;
834
803
  }
835
- function makeJobFailedError(snapshot) {
836
- const error = snapshot.error;
837
- const message = error?.message ?? `Job ended in non-success terminal state: ${snapshot.status}`;
838
- return new HonuaJobFailedError(message, snapshot.status, error?.code, error?.details);
839
- }
840
- function delay(ms, signal) {
841
- return new Promise((resolve) => {
842
- if (signal?.aborted) {
843
- resolve();
844
- return;
845
- }
846
- const onAbort = () => {
847
- clearTimeout(timer);
848
- resolve();
849
- };
850
- const timer = setTimeout(() => {
851
- signal?.removeEventListener("abort", onAbort);
852
- resolve();
853
- }, ms);
854
- signal?.addEventListener("abort", onAbort, { once: true });
855
- });
856
- }
857
804
  export function createHonuaOgcProcesses(client) {
858
805
  return new HonuaOgcProcesses({ client });
859
806
  }
@@ -891,7 +838,7 @@ export async function getOgcProcess(transport, request) {
891
838
  * against the async-only Honua facade, and reports which shape came back.
892
839
  */
893
840
  export async function executeOgcProcess(transport, request) {
894
- const headers = mergeHeaders({ "Content-Type": "application/json", Accept: "application/json" }, request.headers, preferHeaderForExecute(request));
841
+ const headers = executionHeaders(request);
895
842
  const path = `${processesBase(request)}/processes/${encodeURIComponent(request.processId)}/execution`;
896
843
  // `document` is the only response mode the SDK decodes: its body is the
897
844
  // output-id-keyed map that `JobResult.outputs` is defined over. (honua-server
@@ -1008,16 +955,19 @@ export async function cancelOgcProcessJob(transport, request) {
1008
955
  return transport.requestJson("DELETE", withParams(route, params), undefined, request.signal);
1009
956
  }
1010
957
  /**
1011
- * Build the `Prefer` header for an OGC API Processes execution request.
1012
- * `mode: "async"` sends an explicit `Prefer: respond-async` (RFC 7240) so a
1013
- * server that supports both shapes returns a job and OGC conformance checkers
1014
- * see the header. `"sync"` and `"auto"` omit it: Core defines no header that
1015
- * forces synchronous execution, so `"sync"` is expressed by *not* preferring
1016
- * async plus the `sync-execute` capability assertion made before the post.
958
+ * Build the execution preference header defined by OGC API Processes 1.0.
959
+ * Requirement 25 selects synchronous execution by omitting `Prefer`; only an
960
+ * explicit asynchronous request sends the standard `respond-async` token.
961
+ * Clients remain prepared for either response shape.
1017
962
  */
1018
- function preferHeaderForExecute(request) {
963
+ function executionHeaders(request) {
964
+ const headers = new Headers(mergeHeaders({ "Content-Type": "application/json", Accept: "application/json" }, request.headers));
965
+ // `mode` is the sole authority for this protocol semantic. A shared caller
966
+ // header bag must not silently turn `auto` / `sync` into an async request or
967
+ // smuggle a different preference into the explicit async mode.
968
+ headers.delete("Prefer");
1019
969
  if (request.mode === "async") {
1020
- return { Prefer: "respond-async" };
970
+ headers.set("Prefer", "respond-async");
1021
971
  }
1022
- return undefined;
972
+ return headers;
1023
973
  }
@@ -1,4 +1,4 @@
1
- import { HonuaJobPollTimeoutError } from "../contract/jobs.js";
1
+ import { JobRunLifecycle } from "./job-run-lifecycle.js";
2
2
  export class HonuaProcessRunner {
3
3
  adapter;
4
4
  constructor(adapter) {
@@ -89,115 +89,51 @@ class GeospatialGrpcProcessJobRun {
89
89
  id;
90
90
  type = "geospatial-grpc";
91
91
  client;
92
- currentStatus;
93
- currentProgress;
94
- terminalSnapshot;
95
- terminalPromise;
96
- listeners = new Set();
92
+ lifecycle;
97
93
  constructor(options) {
98
94
  this.client = options.client;
99
95
  this.id = readJobId(options.accepted);
100
- this.currentStatus = geospatialGrpcJobStateToStatus(options.accepted.state);
96
+ this.lifecycle = new JobRunLifecycle({
97
+ id: this.id,
98
+ initialStatus: geospatialGrpcJobStateToStatus(options.accepted.state),
99
+ pollIntervalMs: 1_000,
100
+ poll: async (signal) => this.translateJob(await this.client.getJob({ jobId: this.id, signal })),
101
+ });
101
102
  }
102
103
  get status() {
103
- return this.currentStatus;
104
+ return this.lifecycle.status;
104
105
  }
105
106
  get progress() {
106
- return this.currentProgress;
107
+ return this.lifecycle.progress;
107
108
  }
108
109
  async poll() {
109
- if (this.terminalSnapshot)
110
- return this.terminalSnapshot;
111
- const response = await this.client.getJob({ jobId: this.id });
112
- return this.handleJob(response);
110
+ return this.lifecycle.poll();
113
111
  }
114
112
  watch(listener) {
115
- this.listeners.add(listener);
116
- return () => this.listeners.delete(listener);
113
+ return this.lifecycle.watch(listener);
117
114
  }
118
115
  async results(options = {}) {
119
- if (!this.terminalPromise) {
120
- // Reset on failure so an aborted / timed-out poll does not permanently
121
- // poison subsequent results() calls.
122
- this.terminalPromise = this.runUntilTerminal(options).catch((error) => {
123
- this.terminalPromise = undefined;
124
- throw error;
125
- });
126
- }
127
- return this.terminalPromise;
116
+ return this.lifecycle.results(options);
128
117
  }
129
118
  async cancel() {
130
- if (this.terminalSnapshot)
131
- return this.currentStatus;
132
- const response = await this.client.cancelJob({ jobId: this.id });
133
- const snapshot = await this.handleJob(response);
134
- return snapshot.status;
135
- }
136
- async runUntilTerminal(options = {}) {
137
- const { signal } = options;
138
- const baseIntervalMs = options.pollIntervalMs ?? 1_000;
139
- const maxIntervalMs = options.maxPollIntervalMs ?? Math.max(baseIntervalMs, 30_000);
140
- const startedAt = Date.now();
141
- let attempts = 0;
142
- while (!this.terminalSnapshot) {
143
- if (signal?.aborted) {
144
- throw new HonuaJobPollTimeoutError(`Job ${this.id} poll aborted`, "aborted", this.id, this.currentStatus);
145
- }
146
- if (options.maxAttempts !== undefined && attempts >= options.maxAttempts) {
147
- throw new HonuaJobPollTimeoutError(`Job ${this.id} did not reach a terminal state within ${options.maxAttempts} poll attempt(s)`, "max-attempts", this.id, this.currentStatus);
148
- }
149
- let response;
150
- try {
151
- response = await this.client.getJob({ jobId: this.id, signal });
152
- }
153
- catch (error) {
154
- if (signal?.aborted) {
155
- throw new HonuaJobPollTimeoutError(`Job ${this.id} poll aborted`, "aborted", this.id, this.currentStatus);
156
- }
157
- throw error;
158
- }
159
- attempts += 1;
160
- await this.handleJob(response);
161
- if (this.terminalSnapshot)
162
- break;
163
- if (options.deadlineMs !== undefined && Date.now() - startedAt >= options.deadlineMs) {
164
- throw new HonuaJobPollTimeoutError(`Job ${this.id} did not reach a terminal state within ${options.deadlineMs}ms`, "deadline", this.id, this.currentStatus);
165
- }
166
- // Capped exponential backoff instead of a fixed 1s interval.
167
- const intervalMs = Math.min(maxIntervalMs, baseIntervalMs * 2 ** (attempts - 1));
168
- if (intervalMs > 0) {
169
- await wait(intervalMs, signal);
170
- }
171
- }
172
- if (this.terminalSnapshot.status === "successful" && this.terminalSnapshot.result) {
173
- return this.terminalSnapshot.result;
174
- }
175
- throw new Error(this.terminalSnapshot.error?.message ?? `geospatial-grpc job ended in ${this.terminalSnapshot.status}`);
119
+ return this.lifecycle.cancel(async () => this.translateJob(await this.client.cancelJob({ jobId: this.id })));
176
120
  }
177
- async handleJob(response) {
121
+ async translateJob(response) {
178
122
  const jobProgress = "progress" in response ? response.progress : undefined;
179
123
  const status = geospatialGrpcJobStateToStatus(response.state ?? jobProgress?.state);
180
124
  const progress = geospatialGrpcProgress(jobProgress);
181
- this.currentStatus = status;
182
- this.currentProgress = progress;
183
125
  if (status === "successful") {
184
126
  const result = await this.client.getJobResult({ jobId: this.id });
185
127
  const snapshot = geospatialGrpcResultSnapshot(result, progress);
186
- this.terminalSnapshot = snapshot;
187
- this.currentStatus = snapshot.status;
188
- this.notify(snapshot);
189
128
  return snapshot;
190
129
  }
191
130
  if (status === "failed" || status === "dismissed") {
192
131
  const result = await this.safeGetTerminalResult();
193
132
  const error = result ? geospatialGrpcError(result) : undefined;
194
133
  const snapshot = { status, progress, ...(error ? { error } : {}) };
195
- this.terminalSnapshot = snapshot;
196
- this.notify(snapshot);
197
134
  return snapshot;
198
135
  }
199
136
  const snapshot = { status, progress };
200
- this.notify(snapshot);
201
137
  return snapshot;
202
138
  }
203
139
  async safeGetTerminalResult() {
@@ -208,10 +144,6 @@ class GeospatialGrpcProcessJobRun {
208
144
  return undefined;
209
145
  }
210
146
  }
211
- notify(snapshot) {
212
- for (const listener of this.listeners)
213
- listener(snapshot);
214
- }
215
147
  }
216
148
  function requirePlan(request) {
217
149
  if (request.plan === undefined)
@@ -297,20 +229,3 @@ function timestampToIso(value) {
297
229
  return new Date(numeric).toISOString();
298
230
  return String(value);
299
231
  }
300
- function wait(ms, signal) {
301
- return new Promise((resolve) => {
302
- if (signal?.aborted) {
303
- resolve();
304
- return;
305
- }
306
- const onAbort = () => {
307
- clearTimeout(timer);
308
- resolve();
309
- };
310
- const timer = setTimeout(() => {
311
- signal?.removeEventListener("abort", onAbort);
312
- resolve();
313
- }, ms);
314
- signal?.addEventListener("abort", onAbort, { once: true });
315
- });
316
- }